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

Use PostgreSQL ORDER BY for deterministic multi-column sorting, explicit NULL placement, top N, keyset pagination, ties, and windows.

Author: PracHub

Published: 8/14/2026

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

By PracHub
August 14, 2026
22 min read
0
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.

Data AnalystFree

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_idcustomer_idorder_dateamountshipped_atstatus
11012026-03-01120.002026-03-02 10:00:00completed
21022026-03-01250.00NULLprocessing
31032026-03-02250.002026-03-03 09:00:00completed
41042026-03-0280.00NULLcancelled
51052026-03-03NULLNULLpending
61062026-03-03120.002026-03-04 12:00:00completed
71072026-03-0480.00NULLprocessing
81082026-03-04250.002026-03-05 08:00:00completed
SELECT
  order_id,
  amount,
  order_date
FROM orders
ORDER BY
  amount DESC NULLS LAST,
  order_date ASC,
  order_id ASC;
Row flow through a three-expression sort key Eight order rows are compared by descending amount, ascending order date, and ascending order ID, producing eight deterministically ordered rows. 8 order rows3 amount tiesamount, date, IDcompare left to right8 sorted rowsNULL amount last
Later expressions matter only when all earlier expressions compare equal.

Output

order_idamountorder_date
2250.002026-03-01
3250.002026-03-02
8250.002026-03-04
1120.002026-03-01
6120.002026-03-03
480.002026-03-02
780.002026-03-04
5NULL2026-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_idcustomer_idorder_dateamountshipped_atstatus
11012026-03-01120.002026-03-02 10:00:00completed
21022026-03-01250.00NULLprocessing
31032026-03-02250.002026-03-03 09:00:00completed
41042026-03-0280.00NULLcancelled
51052026-03-03NULLNULLpending
61062026-03-03120.002026-03-04 12:00:00completed
71072026-03-0480.00NULLprocessing
81082026-03-04250.002026-03-05 08:00:00completed
SELECT
  order_id,
  shipped_at
FROM orders
ORDER BY
  shipped_at ASC NULLS FIRST,
  order_id ASC;
Row flow through explicit NULL placement Eight order rows divide into four null shipment rows and four known shipment rows, with nulls placed first and each group ordered deterministically. 8 order rows4 shipped, 4 NULLNULL group firstthen shipment time8 sorted rowsIDs resolve NULL ties
A nullable key needs both placement policy and a tie breaker when its rows must be repeatable.

Output

order_idshipped_at
2NULL
4NULL
5NULL
7NULL
12026-03-02 10:00:00
32026-03-03 09:00:00
62026-03-04 12:00:00
82026-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_idcustomer_idorder_dateamountshipped_atstatus
11012026-03-01120.002026-03-02 10:00:00completed
21022026-03-01250.00NULLprocessing
31032026-03-02250.002026-03-03 09:00:00completed
41042026-03-0280.00NULLcancelled
51052026-03-03NULLNULLpending
61062026-03-03120.002026-03-04 12:00:00completed
71072026-03-0480.00NULLprocessing
81082026-03-04250.002026-03-05 08:00:00completed
SELECT
  order_id,
  amount
FROM orders
WHERE amount IS NOT NULL
ORDER BY amount DESC, order_id ASC
LIMIT 3;
Row flow through deterministic top-three sorting Eight order rows filter to seven known amounts, sort by descending amount and ascending order ID, and limit to three tied top rows. 8 order rows7 known amountsComplete ordered listamount then ID3 output rowsall amount 250.00
Without the order ID, PostgreSQL would not promise an order among the three equal amounts.

Output

order_idamount
2250.00
3250.00
8250.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_amountlast_order_id
250.003

Input: orders

order_idcustomer_idorder_dateamountshipped_atstatus
11012026-03-01120.002026-03-02 10:00:00completed
21022026-03-01250.00NULLprocessing
31032026-03-02250.002026-03-03 09:00:00completed
41042026-03-0280.00NULLcancelled
51052026-03-03NULLNULLpending
61062026-03-03120.002026-03-04 12:00:00completed
71072026-03-0480.00NULLprocessing
81082026-03-04250.002026-03-05 08:00:00completed
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;
Row flow for keyset pagination after a compound cursor Seven known-amount rows are compared with the last amount and order ID, five rows fall after the cursor, and the next three rows are returned. 7 known rowscursor after 250, ID 35 rows after cursorsame compound order3 next-page rows8, 1, and 6
The cursor predicate mirrors the mixed descending and ascending sort directions.

Output

order_idamount
8250.00
1120.00
6120.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_idcustomer_idorder_dateamountshipped_atstatus
11012026-03-01120.002026-03-02 10:00:00completed
21022026-03-01250.00NULLprocessing
31032026-03-02250.002026-03-03 09:00:00completed
41042026-03-0280.00NULLcancelled
51052026-03-03NULLNULLpending
61062026-03-03120.002026-03-04 12:00:00completed
71072026-03-0480.00NULLprocessing
81082026-03-04250.002026-03-05 08:00:00completed
SELECT
  order_id,
  amount,
  RANK() OVER (
    ORDER BY amount DESC NULLS LAST
  ) AS amount_rank
FROM orders
ORDER BY amount_rank, order_id;
Row flow from sorted amounts to tied ranks Eight order rows form four amount levels including null, ranks are assigned across the eight rows, and final ordering resolves rows inside each rank by order ID. 8 order rows4 amount levelsAssign tied ranks1, 4, 6, and 88 ranked rowsID orders each tie
Filtering to rank 1 would keep all three amount-250 rows; limiting to one row would keep only one.

Output

order_idamountamount_rank
2250.001
3250.001
8250.001
1120.004
6120.004
480.006
780.006
5NULL8

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_idcustomer_idorder_dateamountshipped_atstatus
11012026-03-01120.002026-03-02 10:00:00completed
21022026-03-01250.00NULLprocessing
31032026-03-02250.002026-03-03 09:00:00completed
41042026-03-0280.00NULLcancelled
51052026-03-03NULLNULLpending
61062026-03-03120.002026-03-04 12:00:00completed
71072026-03-0480.00NULLprocessing
81082026-03-04250.002026-03-05 08:00:00completed
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;
Row flow through window ordering and final ordering Eight order rows receive running totals in date and order-ID sequence, then all eight annotated rows are displayed by descending running revenue. 8 order rowschronological window8 running totalsvalues stay attached8 displayed rowslargest running total first
The two order clauses serve different operations and need not use the same expressions.

Output

order_idorder_dateamountrunning_revenue
82026-03-04250.001150.00
72026-03-0480.00900.00
62026-03-03120.00820.00
42026-03-0280.00700.00
52026-03-03NULL700.00
32026-03-02250.00620.00
22026-03-01250.00370.00
12026-03-01120.00120.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_idcustomer_idorder_dateamountshipped_atstatus
11012026-03-01120.002026-03-02 10:00:00completed
21022026-03-01250.00NULLprocessing
31032026-03-02250.002026-03-03 09:00:00completed
41042026-03-0280.00NULLcancelled
51052026-03-03NULLNULLpending
61062026-03-03120.002026-03-04 12:00:00completed
71072026-03-0480.00NULLprocessing
81082026-03-04250.002026-03-05 08:00:00completed
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;
Row flow through a business-category sort Eight order rows map four status labels to numeric sort positions and produce eight rows in processing, pending, completed, then cancelled order. 8 order rows4 status labelsMap labels to 1–4business priority8 sorted rowsID resolves each group
The ELSE branch gives future or unexpected statuses a defined position.

Output

order_idstatus
2processing
7processing
5pending
1completed
3completed
6completed
8completed
4cancelled

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.


Comments (0)