SQL ORDER BY: Ascending, Descending, Multi-Column Sorting, and Where NULLs Land

Quick Overview
A Data Analyst guide to reliable PostgreSQL ordering. Seven executed walkthroughs cover compound keys, NULL placement, deterministic LIMIT, keyset pagination, tied ranks, window order versus final order, and business-category sorting.
ORDER BY controls the presentation order of a result. It does not change which rows qualify, and without it PostgreSQL does not promise a reusable row order.
A reliable sort key answers three questions: which expressions are compared, which direction applies to each expression, and what breaks ties. Add an explicit NULL policy whenever a nullable expression participates.
Build a complete sort key
Each direction applies only to the expression beside it. This query sorts known amounts from high to low, then uses order date and order ID to make ties deterministic. NULL amounts come last by policy.
Input: orders
| order_id | customer_id | order_date | amount | shipped_at | status |
|---|---|---|---|---|---|
| 1 | 101 | 2026-03-01 | 120.00 | 2026-03-02 10:00:00 | completed |
| 2 | 102 | 2026-03-01 | 250.00 | NULL | processing |
| 3 | 103 | 2026-03-02 | 250.00 | 2026-03-03 09:00:00 | completed |
| 4 | 104 | 2026-03-02 | 80.00 | NULL | cancelled |
| 5 | 105 | 2026-03-03 | NULL | NULL | pending |
| 6 | 106 | 2026-03-03 | 120.00 | 2026-03-04 12:00:00 | completed |
| 7 | 107 | 2026-03-04 | 80.00 | NULL | processing |
| 8 | 108 | 2026-03-04 | 250.00 | 2026-03-05 08:00:00 | completed |
SELECT
order_id,
amount,
order_date
FROM orders
ORDER BY
amount DESC NULLS LAST,
order_date ASC,
order_id ASC;
Output
| order_id | amount | order_date |
|---|---|---|
| 2 | 250.00 | 2026-03-01 |
| 3 | 250.00 | 2026-03-02 |
| 8 | 250.00 | 2026-03-04 |
| 1 | 120.00 | 2026-03-01 |
| 6 | 120.00 | 2026-03-03 |
| 4 | 80.00 | 2026-03-02 |
| 7 | 80.00 | 2026-03-04 |
| 5 | NULL | 2026-03-03 |
ASC is the default direction, but writing it can make a mixed-direction key easier to review. The clause runs late enough to reference a select-list alias; the full sequence is in SQL order of operations.
Place NULLs deliberately
PostgreSQL supports NULLS FIRST and NULLS LAST on each sort expression. Here unshipped rows form the first group, and order ID fixes their internal order.
Input: orders
| order_id | customer_id | order_date | amount | shipped_at | status |
|---|---|---|---|---|---|
| 1 | 101 | 2026-03-01 | 120.00 | 2026-03-02 10:00:00 | completed |
| 2 | 102 | 2026-03-01 | 250.00 | NULL | processing |
| 3 | 103 | 2026-03-02 | 250.00 | 2026-03-03 09:00:00 | completed |
| 4 | 104 | 2026-03-02 | 80.00 | NULL | cancelled |
| 5 | 105 | 2026-03-03 | NULL | NULL | pending |
| 6 | 106 | 2026-03-03 | 120.00 | 2026-03-04 12:00:00 | completed |
| 7 | 107 | 2026-03-04 | 80.00 | NULL | processing |
| 8 | 108 | 2026-03-04 | 250.00 | 2026-03-05 08:00:00 | completed |
SELECT
order_id,
shipped_at
FROM orders
ORDER BY
shipped_at ASC NULLS FIRST,
order_id ASC;
Output
| order_id | shipped_at |
|---|---|
| 2 | NULL |
| 4 | NULL |
| 5 | NULL |
| 7 | NULL |
| 1 | 2026-03-02 10:00:00 |
| 3 | 2026-03-03 09:00:00 |
| 6 | 2026-03-04 12:00:00 |
| 8 | 2026-03-05 08:00:00 |
Top N and pagination need stable ties
LIMIT 3 means three rows, not three distinct amounts. All three highest rows happen to tie at 250, and order ID decides which row would come first within that tie.
Input: orders
| order_id | customer_id | order_date | amount | shipped_at | status |
|---|---|---|---|---|---|
| 1 | 101 | 2026-03-01 | 120.00 | 2026-03-02 10:00:00 | completed |
| 2 | 102 | 2026-03-01 | 250.00 | NULL | processing |
| 3 | 103 | 2026-03-02 | 250.00 | 2026-03-03 09:00:00 | completed |
| 4 | 104 | 2026-03-02 | 80.00 | NULL | cancelled |
| 5 | 105 | 2026-03-03 | NULL | NULL | pending |
| 6 | 106 | 2026-03-03 | 120.00 | 2026-03-04 12:00:00 | completed |
| 7 | 107 | 2026-03-04 | 80.00 | NULL | processing |
| 8 | 108 | 2026-03-04 | 250.00 | 2026-03-05 08:00:00 | completed |
SELECT
order_id,
amount
FROM orders
WHERE amount IS NOT NULL
ORDER BY amount DESC, order_id ASC
LIMIT 3;
Output
| order_id | amount |
|---|---|
| 2 | 250.00 |
| 3 | 250.00 |
| 8 | 250.00 |
Keyset pagination resumes after the full last-seen key. The cursor says the previous page ended at amount 250 and order ID 3, so the next page begins with order 8 and then moves to amount 120.
Input: page_cursor
| last_amount | last_order_id |
|---|---|
| 250.00 | 3 |
Input: orders
| order_id | customer_id | order_date | amount | shipped_at | status |
|---|---|---|---|---|---|
| 1 | 101 | 2026-03-01 | 120.00 | 2026-03-02 10:00:00 | completed |
| 2 | 102 | 2026-03-01 | 250.00 | NULL | processing |
| 3 | 103 | 2026-03-02 | 250.00 | 2026-03-03 09:00:00 | completed |
| 4 | 104 | 2026-03-02 | 80.00 | NULL | cancelled |
| 5 | 105 | 2026-03-03 | NULL | NULL | pending |
| 6 | 106 | 2026-03-03 | 120.00 | 2026-03-04 12:00:00 | completed |
| 7 | 107 | 2026-03-04 | 80.00 | NULL | processing |
| 8 | 108 | 2026-03-04 | 250.00 | 2026-03-05 08:00:00 | completed |
SELECT
o.order_id,
o.amount
FROM orders AS o
CROSS JOIN page_cursor AS c
WHERE o.amount IS NOT NULL
AND (
o.amount < c.last_amount
OR (
o.amount = c.last_amount
AND o.order_id > c.last_order_id
)
)
ORDER BY o.amount DESC, o.order_id ASC
LIMIT 3;
Output
| order_id | amount |
|---|---|
| 8 | 250.00 |
| 1 | 120.00 |
| 6 | 120.00 |
The predicate assumes immutable sort keys while pages are consumed. If amount changes between requests, pagination semantics need a snapshot or another stability policy.
Sorting rows and ranking ties are different
ORDER BY sequences rows. RANK assigns equal ranks to equal amounts, which is useful when the business question cares about tied levels. The next distinct amount starts at rank 4 because three rows share rank 1.
Input: orders
| order_id | customer_id | order_date | amount | shipped_at | status |
|---|---|---|---|---|---|
| 1 | 101 | 2026-03-01 | 120.00 | 2026-03-02 10:00:00 | completed |
| 2 | 102 | 2026-03-01 | 250.00 | NULL | processing |
| 3 | 103 | 2026-03-02 | 250.00 | 2026-03-03 09:00:00 | completed |
| 4 | 104 | 2026-03-02 | 80.00 | NULL | cancelled |
| 5 | 105 | 2026-03-03 | NULL | NULL | pending |
| 6 | 106 | 2026-03-03 | 120.00 | 2026-03-04 12:00:00 | completed |
| 7 | 107 | 2026-03-04 | 80.00 | NULL | processing |
| 8 | 108 | 2026-03-04 | 250.00 | 2026-03-05 08:00:00 | completed |
SELECT
order_id,
amount,
RANK() OVER (
ORDER BY amount DESC NULLS LAST
) AS amount_rank
FROM orders
ORDER BY amount_rank, order_id;
Output
| order_id | amount | amount_rank |
|---|---|---|
| 2 | 250.00 | 1 |
| 3 | 250.00 | 1 |
| 8 | 250.00 | 1 |
| 1 | 120.00 | 4 |
| 6 | 120.00 | 4 |
| 4 | 80.00 | 6 |
| 7 | 80.00 | 6 |
| 5 | NULL | 8 |
Use SQL DISTINCT when the required output is unique values rather than ranked rows. Window ranking choices are developed in the window-functions guide.
Window order and final order have separate jobs
The window order below computes revenue in chronological order. The final ORDER BY then presents the largest cumulative values first. Changing the final order would not recompute the running totals.
Input: orders
| order_id | customer_id | order_date | amount | shipped_at | status |
|---|---|---|---|---|---|
| 1 | 101 | 2026-03-01 | 120.00 | 2026-03-02 10:00:00 | completed |
| 2 | 102 | 2026-03-01 | 250.00 | NULL | processing |
| 3 | 103 | 2026-03-02 | 250.00 | 2026-03-03 09:00:00 | completed |
| 4 | 104 | 2026-03-02 | 80.00 | NULL | cancelled |
| 5 | 105 | 2026-03-03 | NULL | NULL | pending |
| 6 | 106 | 2026-03-03 | 120.00 | 2026-03-04 12:00:00 | completed |
| 7 | 107 | 2026-03-04 | 80.00 | NULL | processing |
| 8 | 108 | 2026-03-04 | 250.00 | 2026-03-05 08:00:00 | completed |
SELECT
order_id,
order_date,
amount,
SUM(COALESCE(amount, 0)) OVER (
ORDER BY order_date, order_id
ROWS UNBOUNDED PRECEDING
) AS running_revenue
FROM orders
ORDER BY running_revenue DESC, order_id;
Output
| order_id | order_date | amount | running_revenue |
|---|---|---|---|
| 8 | 2026-03-04 | 250.00 | 1150.00 |
| 7 | 2026-03-04 | 80.00 | 900.00 |
| 6 | 2026-03-03 | 120.00 | 820.00 |
| 4 | 2026-03-02 | 80.00 | 700.00 |
| 5 | 2026-03-03 | NULL | 700.00 |
| 3 | 2026-03-02 | 250.00 | 620.00 |
| 2 | 2026-03-01 | 250.00 | 370.00 |
| 1 | 2026-03-01 | 120.00 | 120.00 |
Business categories need a stated order rather than alphabetic coincidence. A CASE expression maps each status to a sort position; order ID resolves rows within a status.
Input: orders
| order_id | customer_id | order_date | amount | shipped_at | status |
|---|---|---|---|---|---|
| 1 | 101 | 2026-03-01 | 120.00 | 2026-03-02 10:00:00 | completed |
| 2 | 102 | 2026-03-01 | 250.00 | NULL | processing |
| 3 | 103 | 2026-03-02 | 250.00 | 2026-03-03 09:00:00 | completed |
| 4 | 104 | 2026-03-02 | 80.00 | NULL | cancelled |
| 5 | 105 | 2026-03-03 | NULL | NULL | pending |
| 6 | 106 | 2026-03-03 | 120.00 | 2026-03-04 12:00:00 | completed |
| 7 | 107 | 2026-03-04 | 80.00 | NULL | processing |
| 8 | 108 | 2026-03-04 | 250.00 | 2026-03-05 08:00:00 | completed |
SELECT
order_id,
status
FROM orders
ORDER BY
CASE status
WHEN 'processing' THEN 1
WHEN 'pending' THEN 2
WHEN 'completed' THEN 3
WHEN 'cancelled' THEN 4
ELSE 5
END,
order_id;
ELSE branch gives future or unexpected statuses a defined position.Output
| order_id | status |
|---|---|
| 2 | processing |
| 7 | processing |
| 5 | pending |
| 1 | completed |
| 3 | completed |
| 6 | completed |
| 8 | completed |
| 4 | cancelled |
The SQL CASE guide covers condition ordering and unmatched values.
FAQ
Is row order guaranteed without ORDER BY?
No reusable ordering contract exists without an outer ORDER BY. A plan, index, or small fixture can make results look stable without promising that order for another execution.
Does DESC apply to every later column?
No. Direction belongs to one expression. Write the direction beside every expression when a key mixes ascending and descending order.
How should NULL values be sorted?
Choose the business policy and state it with NULLS FIRST or NULLS LAST in PostgreSQL. Add another expression when multiple NULL rows need deterministic order.
Why does LIMIT need a tie breaker?
If rows compare equal on the listed key, any of them can occupy the cutoff position. A stable unique tie breaker makes selection and pagination repeatable for unchanged data.
Is ORDER BY inside OVER the final output order?
No. It defines the sequence used by that window calculation. The outer query's ORDER BY controls displayed row order.
Related Articles
Coderbyte SQL Assessment Guide: Query Types, Timing, and What Employers See
Learn Coderbyte SQL assessment query types, timing, grading, employer reports, common mistakes, and a practical seven-day preparation plan for candidates.
Capital One Data Analyst Internship 2027: VJT, Power Day, and Why There May Be No CodeSignal
Capital One Data Analyst Internship 2027 guide: VJT, Power Day cases, behavioral interviews, SQL prep, timelines, and why CodeSignal may be skipped.
SQL String Functions: SUBSTRING, SPLIT_PART, CONCAT, and LIKE in Interviews
Use PostgreSQL string functions for normalization, SUBSTRING and SPLIT_PART parsing, NULL-safe labels, ordered lists, LIKE, and row splitting.
SQL SELECT DISTINCT: What It Actually Deduplicates, and When It Hides a Bug
Understand SQL DISTINCT across full rows, NULLs, counts, groups, latest-row selection, and join fanout using verified PostgreSQL outputs.
Comments (0)