The GROUP BY model: rows into buckets
GROUP BY collapses many rows into one row per distinct key. The iron rule follows from that collapse: every SELECT expression must be in GROUP BY or wrapped in an aggregate.
A junior ships SELECT user_id, status, SUM(total) FROM orders GROUP BY user_id. Locally it returns clean per-user totals. In review it won’t even run: ERROR: column "orders.status" must appear in the GROUP BY clause or be used in an aggregate function. That error is not Postgres being pedantic — it’s the database telling you the query asked an impossible question. Understand why it’s impossible and you’ll never fight GROUP BY again.
Grouping is bucketing
GROUP BY takes the rows that survived WHERE and sorts them into buckets by the value of the grouping key. All rows with the same key land in the same bucket. Then — this is the whole idea — each bucket collapses into exactly one output row.
Picture five orders. Group by status:
SELECT status, COUNT(*) AS n, SUM(total) AS revenue
FROM orders
GROUP BY status;Postgres partitions the rows into one bucket per distinct status (paid, pending, cancelled, …) and emits one row per bucket. The output is no longer “orders” — it’s “statuses”, one row each.
The iron rule, and why it’s not arbitrary
Once a bucket holds three orders, ask: what is the id of that bucket? There isn’t one — there are three. What’s the created_at? Three again. The bucket has one defined status (that’s the key) but no single value for any other raw column. So Postgres faces a contradiction: you asked for one output row per bucket, but SELECT id demands a value the bucket doesn’t have.
That’s the rule, and it falls straight out of the collapse:
Every expression in the
SELECTlist must be either listed inGROUP BY(so it has one value per bucket) or wrapped in an aggregate (which computes one value from the bucket).
COUNT(*), SUM(total), MAX(created_at), array_agg(id) — every aggregate takes the whole bucket and returns one scalar. Anything not aggregated must be constant within the bucket, which is exactly what putting it in GROUP BY guarantees. The “must appear in GROUP BY or be used in an aggregate” error is Postgres refusing to silently pick one arbitrary row’s value — which is what MySQL’s old default did, and what silently produced wrong reports for a decade.
-- WRONG: status is neither grouped nor aggregated
SELECT user_id, status, SUM(total)
FROM orders
GROUP BY user_id; -- ERROR: status must appear in GROUP BY or an aggregate
-- RIGHT, option A: group by it too (one row per user+status pair)
SELECT user_id, status, SUM(total)
FROM orders
GROUP BY user_id, status;
-- RIGHT, option B: aggregate it away (one row per user)
SELECT user_id, MAX(status) AS some_status, SUM(total)
FROM orders
GROUP BY user_id;The functional-dependency exception
There is one place Postgres relaxes the rule, and it’s not a loophole — it’s correctness. If you GROUP BY a table’s primary key, every other column of that table is functionally dependent on the key: the key uniquely determines the row, so each bucket holds exactly one row of that table, and every column has a single well-defined value. Postgres knows this from the catalog and lets you select those dependent columns ungrouped:
-- u.id is the PK of users, so u.name and u.email are functionally
-- dependent on it — Postgres allows them without listing them in GROUP BY:
SELECT u.id, u.name, u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id; -- not u.id, u.name, u.email — the PK is enoughThis is part of the SQL standard (the SQL:1999 functional-dependency rule), and Postgres implements it for primary keys and other not-null unique constraints. It saves you from padding GROUP BY with a dozen columns just to print them.
How the collapse actually runs
The bucketing is not free — and the planner has two ways to do it. On a 12M-row orders table, GROUP BY user_id into ~2M buckets shows up in EXPLAIN ANALYZE as one of two nodes. HashAggregate builds an in-memory hash table keyed by user_id, one entry per bucket holding the running aggregate state — no sorted input needed, but it must hold every distinct key in memory at once. GroupAggregate needs its input already sorted by user_id (usually via a preceding Sort or an index scan), then streams one bucket at a time, emitting and discarding each before the next — near-constant memory, but it pays for the sort. The planner picks HashAggregate when it estimates the hash table fits in work_mem, and switches to sort-based GroupAggregate when it doesn’t.
▸Why this works
Concrete shapes from EXPLAIN (ANALYZE, BUFFERS) on that 12M-row table. With work_mem = 4MB (the default) and ~2M distinct user_id groups, the hash table needs far more than 4MB, so PG14+ shows HashAggregate ... Disk Usage: 320000 kB (it spills hash partitions to a temp file) or the planner falls back to Sort + GroupAggregate with Sort Method: external merge Disk: 285000 kB — both meaning the aggregation went to disk and the node jumped from ~900ms to ~4–6s. Raise SET work_mem = '256MB' and the same query reports HashAggregate ... Batches: 1 Memory Usage: 210000 kB with no Disk line, dropping back to ~1.1s. Rule of thumb: roughly distinct_groups × ~50–100 bytes of per-group state must fit in work_mem or you spill. A query grouping into 50 buckets never spills; one grouping into millions almost always does on the 4MB default.
▸Edge cases
The dependency must be one Postgres can prove from a constraint. Grouping by u.id works because it’s the declared primary key. Grouping by a column that merely happens to be unique in your data, but has no UNIQUE/PRIMARY KEY constraint, is still rejected — the planner can’t trust runtime uniqueness, only declared constraints. And the dependency is per-table: grouping by users.id lets you select other users columns ungrouped, but not raw orders columns, since one user can own many orders.
Why does SELECT user_id, status, SUM(total) FROM orders GROUP BY user_id fail?
SELECT u.id, u.name, u.email, COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id=u.id GROUP BY u.id is accepted. Why are u.name and u.email allowed without listing them?
Fill in the blank: GROUP BY sorts rows into buckets, then collapses each bucket into one output row — so a column you SELECT must either be the bucket's key or be _______ from the whole bucket.
- 01State the GROUP BY select-list rule and explain why it exists.
- 02What is the functional-dependency exception and when does it apply?
- 03Give two valid fixes for SELECT user_id, status, SUM(total) FROM orders GROUP BY user_id and say how the results differ.
GROUP BY is a bucketing operation: rows that survive WHERE are partitioned by the grouping key, and each bucket collapses into exactly one output row. That collapse is the source of the iron rule — every SELECT expression must be in GROUP BY (constant per bucket) or inside an aggregate (one value computed from the bucket), because a raw column has no single value across a multi-row bucket. Postgres enforces this rather than silently returning an arbitrary row, which is the discipline that keeps reports correct. The single relaxation is the functional-dependency exception: group by a primary key and you may select that table’s other columns ungrouped, since the key determines them. Next we look at when filtering happens around this collapse — WHERE before grouping versus HAVING after — which decides both correctness and performance.
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.