open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 01 · 04

DISTINCT, DISTINCT ON, and set operations

DISTINCT and UNION dedup by sorting or hashing the whole result — real cost. UNION ALL skips it. And DISTINCT ON is Postgres's idiomatic 'latest row per group'.

SQL Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

A reporting endpoint stitched together “active users this week” from two tables with UNION. It was fine in staging. In production it ran for 11 seconds and pinned a CPU, because UNION silently sorted 9 million combined rows just to remove duplicates that couldn’t exist — the two sources were already disjoint. Switching one keyword to UNION ALL dropped it to 600 ms. The lesson: deduplication is never free, and most of the time you’re paying for it without needing it.

DISTINCT removes duplicate rows — by sorting or hashing

SELECT DISTINCT collapses identical output rows into one. “Identical” means all selected columns match. The mechanism matters: to find duplicates, Postgres must either sort the result (then adjacent equal rows are obvious) or build a hash of every row. Both are O(n) work over the full result set, plus memory; if the set is larger than work_mem, the sort or hash spills to disk, which is where the latency cliffs come from.

-- one row per distinct (country, plan) pair seen in users
SELECT DISTINCT country, plan FROM users;

So DISTINCT is not a free “clean up” — it’s a sort-or-hash over everything you selected. If you’re reaching for it to paper over duplicate rows that a missing join condition produced, fix the join instead; DISTINCT hides the bug and adds cost.

DISTINCT ON: Postgres’s “latest row per group”

A Postgres-specific extension solves a question plain SQL handles clumsily: “give me one whole row per group — specifically the latest one.” DISTINCT ON (expr) keeps the first row for each distinct value of expr, and “first” is decided by ORDER BY. The rule that trips everyone: the ORDER BY must start with the same expression(s) as DISTINCT ON, then add the tiebreaker that picks which row wins.

-- latest order per user: one row each, the most recent by created_at
SELECT DISTINCT ON (user_id) user_id, id, total, created_at
FROM orders
ORDER BY user_id, created_at DESC;   -- user_id first (required), then newest wins

This is dramatically cleaner than the portable alternatives (a correlated subquery, or a window-function ROW_NUMBER() filtered to = 1, which you’ll meet in the window-functions unit). For “newest/best row per key”, DISTINCT ON is the idiomatic Postgres tool.

UNION vs UNION ALL: the dedup tax

Set operators stack two result sets vertically. The pivotal distinction:

  • UNION combines both sides and removes duplicate rows — which means it sorts or hashes the entire combined result, exactly like DISTINCT.
  • UNION ALL combines both sides and keeps everything, including duplicates. No dedup step, no sort, no hash — it just concatenates the streams.

The production rule: default to UNION ALL; use UNION only when duplicates are actually possible and you actually want them removed. When the two inputs are disjoint by construction (different statuses, different date ranges, different tables that can’t share a row), UNION is pure wasted sorting. The opening incident was precisely this — disjoint sources, a needless dedup of 9 million rows.

INTERSECT, EXCEPT, and the column rules

Two more set operators, both of which also dedup by default:

  • INTERSECT returns rows present in both sides.
  • EXCEPT returns rows in the first side that are not in the second (set difference; other dialects call it MINUS).

All set operators share strict structural rules: each side must have the same number of columns, and corresponding columns must have compatible types. The output column names come from the first query; the others are ignored for naming. And one NULL nuance that ties back to the previous lesson — for set-operator deduplication, two NULLs are treated as equal (so UNION collapses duplicate NULL rows), which is the opposite of how = treats them. That’s an intentional special case for grouping/dedup contexts.

When you see a set operation failing with a type mismatch, the column count check and a CAST on the mismatched column in one branch fixes it. If the NULL behaviour surprises you in a UNION result, remember: dedup contexts treat NULLs as equal — the same rule applies to GROUP BY and DISTINCT.

Why this works

Why does UNION dedup but JOIN doesn’t? Because they answer different questions. A JOIN matches rows across tables by a condition and can legitimately multiply rows (one order, many items). Set operators stack rows that already share the same shape and ask “what’s the combined set?” — and a set, by definition, has no duplicates, so UNION/INTERSECT/EXCEPT enforce set semantics with a dedup pass. UNION ALL is the escape hatch that says “I want a bag (multiset), not a set — skip the dedup.” Choosing it is asserting you don’t need set semantics, which is usually true and almost always faster.

Order the steps

Order the steps to write a correct 'latest row per user' query with DISTINCT ON:

  1. 1 Decide the group key — the column you want one row per (user_id)
  2. 2 Write DISTINCT ON (user_id) and list the columns you need
  3. 3 Start ORDER BY with the same key: ORDER BY user_id
  4. 4 Append the tiebreaker that picks the winning row: , created_at DESC
  5. 5 Verify exactly one row per user_id comes back, newest first
Quiz

Two SELECTs are known to produce disjoint rows (no value can appear in both). Which combiner is correct and fastest?

Quiz

What does DISTINCT ON (user_id) ... ORDER BY user_id, created_at DESC return?

Recall before you leave
  1. 01
    Why is UNION more expensive than UNION ALL, and when should each be used?
  2. 02
    How does DISTINCT ON pick which row to keep, and what's the ORDER BY rule?
  3. 03
    State the structural rules for set operators and the NULL nuance.
Recap

Deduplication is the through-line. SELECT DISTINCT collapses identical rows by sorting or hashing the entire result — real CPU and memory, with a disk spill once it passes work_mem — so it’s not a free cleanup, and using it to mask join-induced duplicates hides a bug. The set operators UNION, INTERSECT, and EXCEPT all dedup the same way; the senior reflex is to default to UNION ALL (a plain concatenation, no dedup pass) and reserve UNION for the cases where duplicates can actually occur and you want them gone — switching a needless UNION to UNION ALL can turn an 11-second query into a 600-millisecond one. For “one row per group, specifically the latest”, DISTINCT ON (key) paired with ORDER BY key, tiebreaker is the idiomatic Postgres answer, cleaner than a correlated subquery and a precursor to the window-function patterns in unit 04. Set operators require matching column counts and compatible types, take their names from the first branch, and — uniquely — treat two NULLs as equal when deduplicating. Now when you write a UNION and the query is slower than expected, ask whether the two inputs can actually share a row — if not, UNION ALL is the fix and the cost drops immediately.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.