← databases book ⊞ All topics

SQL Fundamentals — Joins, Aggregations, Subqueries & CTEs

SQL is a declarative language: you describe the shape of the result set you want, and the database engine decides how to produce it — which index to use, which join algorithm to run, in what order to combine tables. That gap between the query you write and the plan the engine builds is why two differently-worded queries can compile to the identical plan, and why moving one predicate four characters — from ON to WHERE — can silently change which rows come back at all.

Key Components

Logical evaluation order
The fixed sequence — FROM/JOIN, WHERE, GROUP BY, HAVING, SELECT, ORDER BY — in which the database evaluates clauses, no matter what order you typed them in.
Join
The mechanism that reassembles rows from two tables: form the full pairing of every row against every row (a Cartesian product), keep only the pairs that satisfy a condition, and — for outer joins — add back the rows that had no match.
Aggregate function
A set-to-one-value reduction, such as COUNT, SUM, or AVG — the only legal way to reference a column once GROUP BY has changed one output row from meaning "one source row" to meaning "one group of source rows."
Three-valued logic
SQL comparisons return TRUE, FALSE, or UNKNOWN. Any comparison against NULL — including x <> NULL — evaluates to UNKNOWN, not FALSE.
Correlated subquery
A subquery that references a column from the query around it, so it is conceptually re-evaluated once per outer row (the planner usually turns this into a single join instead).
CTE (Common Table Expression)
A named subquery introduced with WITH, defined once and referenced by name later in the same statement — the standard SQL tool for recursion.

Concrete Example

One running scenario — merchants, their transactions, and their settlements — carries through all four parts below, because joins, aggregation, subqueries, and CTEs are really four different questions asked about the same shape of data.

Joins — the ON-vs-WHERE trap on an outer join

Take: "settlement total per merchant for June, including merchants with no June settlements at all." That "including the zeroes" requirement forces a LEFT JOIN, and where the date predicate goes decides whether it actually works.

-- CORRECT: the date test is part of "what counts as a match"
SELECT m.name, COALESCE(SUM(s.amount), 0) AS june_total
FROM merchants m
LEFT JOIN settlements s
       ON s.merchant_id = m.merchant_id
      AND s.settled_on >= DATE '2025-06-01'
      AND s.settled_on <  DATE '2025-07-01'
GROUP BY m.name;

-- WRONG: silently an INNER JOIN
SELECT m.name, COALESCE(SUM(s.amount), 0) AS june_total
FROM merchants m
LEFT JOIN settlements s ON s.merchant_id = m.merchant_id
WHERE s.settled_on >= DATE '2025-06-01'
  AND s.settled_on <  DATE '2025-07-01'
GROUP BY m.name;

The second query does pad in NULL rows for merchants with zero June settlements — then WHERE runs and tests NULL >= '2025-06-01', which is UNKNOWN, so every padded row is thrown away. The LEFT keyword is still sitting on the page, and it has been completely neutralised. Rule: a predicate on the nullable (inner) side of an outer join belongs in ON; a predicate on the preserved side belongs in WHERE.

Aggregation — five counts in one pass

Conditional aggregation turns rows into columns without a pivot table, and is the highest-value aggregation pattern for real reporting work:

SELECT merchant_id,
       COUNT(*)                                    AS total,
       COUNT(*) FILTER (WHERE status = 'SUCCESS')  AS succeeded,
       COUNT(*) FILTER (WHERE status = 'FAILED')   AS failed,
       SUM(amount) FILTER (WHERE method = 'UPI')   AS upi_volume,
       ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'SUCCESS')
                   / NULLIF(COUNT(*), 0), 2)       AS success_pct
FROM transactions
GROUP BY merchant_id;

NULLIF(COUNT(*), 0) converts a 0 denominator to NULL, so the division returns NULL instead of erroring — a guard worth adding to every percentage computed in SQL.

Subqueries — NOT IN vs NOT EXISTS

"Customers who have never transacted" looks like a textbook NOT IN, and fails silently the moment the referenced column can hold a NULL:

-- Returns ZERO ROWS if transactions.customer_id contains even one NULL
SELECT * FROM customers
WHERE customer_id NOT IN (SELECT customer_id FROM transactions);

-- Correct, and usually the plan the first query should have gotten anyway
SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM transactions t WHERE t.customer_id = c.customer_id);

NOT EXISTS asks a cardinality question — did any row come back? — which has exactly two answers. UNKNOWN has nowhere to enter. NOT IN asks a value-comparison question, and one stray NULL in the subquery's result poisons every row.

CTEs — naming a two-step pipeline

The top-3-merchants-per-month report reads as a pipeline instead of a pyramid of nested subqueries:

WITH monthly AS (
    SELECT merchant_id,
           date_trunc('month', created_at) AS month,
           SUM(amount) AS volume
    FROM transactions
    WHERE status = 'SUCCESS'
    GROUP BY 1, 2
),
ranked AS (
    SELECT *, RANK() OVER (PARTITION BY month ORDER BY volume DESC) AS rk
    FROM monthly
)
SELECT month, merchant_id, volume
FROM ranked
WHERE rk <= 3
ORDER BY month, rk;

Each named step reads top to bottom. Note that WHERE rk <= 3 is legal here only because rk is a plain output column of the ranked CTE by the time the outer query's WHERE runs against it — rk is not filtered inside the same SELECT that computed it.

Visual Model

Here's the mental model worth keeping: a query is a small pipeline, and the order you type its clauses in has almost nothing to do with the order the database runs them in. Step through the trace below. The query on the left never changes — only which line is currently executing, and what that does to the data on the right.

Step 1 of 6

Written order

SELECT merchant_id, COUNT(*) AS n, AVG(amount) AS avg_amt
FROM transactions
WHERE status = 'SUCCESS'
GROUP BY merchant_id
HAVING COUNT(*) > 1
ORDER BY avg_amt DESC;

Evaluated order

transactions
idmerchant_idstatusamount
1M1SUCCESS120
2M1SUCCESS340
3M1FAILED90
4M2SUCCESS60
5M2SUCCESS45
6M3SUCCESS500
7M3FAILED200

FROM assembles the source rows. All 7 transactions exist right now — nothing has been filtered, grouped, or projected yet.

Aside: why can't a window function go in WHERE?

A window function like RANK() OVER (...) is computed as part of this same SELECT step — after WHERE, GROUP BY, and HAVING have already run and already discarded whatever they were going to discard. That is why WHERE rk = 1 fails with "column rk does not exist": at the moment WHERE evaluates, the window function hasn't produced a value yet. The standard workaround is the one used in the CTE example above — compute the window function inside a CTE, then filter on it in a later, separate SELECT. See Window Functions for the full PARTITION BY and frame mechanics.

Deeper — Edge Cases & Gotchas

Anti-pattern: join fan-out inflates every SUM

Anti-pattern: joining two independent one-to-many branches off the same parent, then aggregating, without noticing the row count exploded first.
-- WRONG: txn_total is inflated by the number of settlement rows
SELECT m.name, SUM(t.amount) AS txn_total, SUM(s.amount) AS settled_total
FROM merchants m
JOIN transactions t ON t.merchant_id = m.merchant_id
JOIN settlements  s ON s.merchant_id = m.merchant_id
GROUP BY m.name;

If a merchant has 80 transactions and 12 settlements, the join produces 80 × 12 = 960 rows for that merchant, and every transaction amount is now repeated 12 times. Nothing errors — the report is simply false, and it looks plausible. The fix is to aggregate each branch to one row per key before joining: LEFT JOIN (SELECT merchant_id, SUM(amount) AS txn_total FROM transactions GROUP BY merchant_id) t USING (merchant_id), and the same for settlements. Neither branch can fan out the other once each is already one row per merchant_id. Diagnostic: run COUNT(*) before and after adding a join — if it grows, every pre-existing SUM in the query is now wrong.

Anti-pattern: NOT IN with a nullable column

Anti-pattern: WHERE customer_id NOT IN (SELECT customer_id FROM transactions) when transactions.customer_id can be NULL.
-- subquery returns {7, 12, NULL}; NOT IN means customer_id <> 7 AND <> 12 AND <> NULL
-- customer 99 (never transacted): TRUE AND TRUE AND UNKNOWN = UNKNOWN -> dropped
-- customer 7  (did transact):     FALSE AND ...               = FALSE   -> dropped
-- every row is FALSE or UNKNOWN, so the result set is empty -- and looks like a legitimate finding

One NULL anywhere in the subquery's result poisons the whole comparison, because NOT IN is <> ALL and any comparison against NULL is UNKNOWN, never TRUE. Three fixes: rewrite as NOT EXISTS (safe by construction — a cardinality test, not a value comparison); rewrite as LEFT JOIN ... WHERE t.customer_id IS NULL (the same anti-join spelled as a join); or filter the NULLs out of the subquery explicitly with WHERE customer_id IS NOT NULL. Related trap: on the nullable side of a LEFT JOIN, WHERE s.merchant_id IS NOT NULL degrades the join back to an INNER JOIN — it discards exactly the padded rows the LEFT was there to preserve. Only IS NULL does useful work there.

CTEs are not always an optimization fence

Before Postgres 12, a CTE always materialized into a temporary result first — predicates could never be pushed down into it. Postgres 12 changed the default: a CTE referenced exactly once, that is non-recursive and side-effect-free, is now inlined automatically, as if it were never a separate CTE at all. Referenced more than once, it is still materialized by default. Both directions are overridable:

-- Referenced once -> inlined by default. Index on key IS used.
WITH w AS (SELECT * FROM big_table)
SELECT * FROM w WHERE key = 123;

-- Force pushdown even when referenced twice:
WITH w AS NOT MATERIALIZED (SELECT * FROM big_table)
SELECT * FROM w AS w1 JOIN w AS w2 ON w1.key = w2.ref WHERE w2.key = 123;

-- Force a one-time computation, to avoid re-running an expensive expression per reference:
WITH w AS MATERIALIZED (
    SELECT key, very_expensive_function(val) AS f FROM some_table
)
SELECT * FROM w AS w1 JOIN w AS w2 ON w1.f = w2.f;

"CTEs are an optimization fence" was true before Postgres 12 and is false as a blanket claim today — the precise statement is: inlined when non-recursive, side-effect-free, and referenced exactly once; materialized otherwise; and both are overridable with MATERIALIZED / NOT MATERIALIZED.

Recursive CTEs terminate only if you make them

The evaluation is iterative, not truly recursive: evaluate the anchor term once into a working table, then repeatedly evaluate the recursive term against only the previous iteration's working table, until an iteration adds no new rows. UNION deduplicates and so terminates on a cyclic graph; UNION ALL does not, and will spin forever on one. For a graph with cycles, carry a path array and stop on revisit:

WITH RECURSIVE walk AS (
    SELECT id, link, ARRAY[id] AS path, false AS is_cycle
    FROM graph WHERE id = 1
  UNION ALL
    SELECT g.id, g.link, w.path || g.id, g.id = ANY(w.path)
    FROM graph g JOIN walk w ON g.id = w.link
    WHERE NOT w.is_cycle
)
SELECT * FROM walk;

A LIMIT on the outer query genuinely stops the recursion early — Postgres evaluates only as many rows of a WITH query as the parent actually fetches — which is a useful safety net while developing one of these.

Data-modifying CTEs share one snapshot

WITH can contain INSERT, UPDATE, DELETE, or MERGE, each with RETURNING — the pattern behind an atomic archive-and-delete in one statement. Three rules are all counter-intuitive: RETURNING's output, not the modified table, is what the rest of the query can see; a data-modifying CTE always runs to completion even if the outer query never references its output; and every sub-statement shares one snapshot, so none can see another's writes:

WITH t AS (UPDATE products SET price = price * 1.05 RETURNING *)
SELECT * FROM products;   -- ORIGINAL prices: same snapshot, can't see the UPDATE

WITH t AS (UPDATE products SET price = price * 1.05 RETURNING *)
SELECT * FROM t;          -- UPDATED prices: reads the RETURNING output, not the table

That one-snapshot rule is a direct consequence of Postgres's MVCC model — one statement, one snapshot.

Why AVG hides its NULL bug better than COUNT

Aggregates skip NULLs. COUNT(*) counts rows; COUNT(customer_id) counts only its non-NULL values — the gap between the two numbers is visible side by side. AVG gives no such tell: AVG(x) over 10, 20, NULL, NULL divides by 2, not 4, and returns one plausible-looking number either way. Deciding whether NULLs should count as zero (AVG(COALESCE(x, 0))) or be excluded (AVG(x)) is a real choice — making it without noticing is the bug.

Test Yourself

A report joins merchants to transactions and to settlements, then sums both. The totals come back far too high, but the query runs without any error. What's the most likely cause?

WHERE customer_id NOT IN (SELECT customer_id FROM transactions) returns zero rows, even though several customers plainly never transacted. What's the most direct fix?

What would happen if the settlement report's date-range predicate were moved from ON into WHERE?