Aggregation collapses many rows into one. The syntax is small; what trips people up is which
columns are allowed in the SELECT list, where a filter belongs, and what
count() does with NULL.
The aggregates
SELECT count(*) AS bookings,
sum(total) AS revenue,
round(avg(total), 2) AS average,
min(check_in) AS earliest,
max(check_in) AS latest
FROM bookings
WHERE status = 'CONFIRMED';With no GROUP BY, the whole table is one group and you get exactly one row — even
if the table is empty, in which case count(*) is 0 and every other aggregate is
NULL.
count(*) is not count(column)
SELECT count(*) AS rows,
count(cancelled_at) AS actually_cancelled,
count(DISTINCT property_id) AS distinct_properties
FROM bookings;count(*)counts rows.count(column)counts rows where that column is not NULL. This is genuinely useful — the second column above is a count of cancellations — and it is also the reason a count you expected to match does not.count(DISTINCT column)counts distinct non-NULL values, and is much more expensive than the other two.
Every aggregate except count(*) ignores NULL. avg() over a column
where half the rows are NULL divides by the half that are not, which is usually right and
occasionally very wrong.
GROUP BY
SELECT status,
count(*) AS bookings,
round(avg(nights), 2) AS avg_nights
FROM bookings
GROUP BY status
ORDER BY bookings DESC; status | bookings | avg_nights
-----------+----------+------------
COMPLETED | 200612 | 3.49
CONFIRMED | 152854 | 3.52
PENDING | 25482 | 3.52
CANCELLED | 21052 | 3.50The rule: every column in the SELECT list must either be in the
GROUP BY or be inside an aggregate. Postgres enforces it, which is a mercy — MySQL
historically did not and would return an arbitrary row's value for the ungrouped column.
There is one exception, and it is a good one. Group by a primary key and Postgres knows every other column of that table is determined by it, so you may select them freely:
SELECT p.id, p.title, p.city, count(b.id) AS stays
FROM properties p
LEFT JOIN bookings b ON b.property_id = p.id
GROUP BY p.id -- title and city come along, because id is the PK
ORDER BY stays DESC
LIMIT 5;WHERE, then GROUP BY, then HAVING
SELECT p.city, count(*) AS stays, round(avg(b.total), 2) AS avg_total
FROM bookings b
JOIN properties p ON p.id = b.property_id
WHERE b.status = 'CONFIRMED' -- filters ROWS, before grouping
GROUP BY p.city
HAVING count(*) > 5000 -- filters GROUPS, after
ORDER BY stays DESC;The difference is not stylistic. WHERE runs first and removes rows before the
aggregation does any work; HAVING runs after and can only test aggregate results. A
condition that could go in either belongs in WHERE, because it makes the grouping
smaller.
The full order Postgres evaluates in, which explains most "column does not exist" errors:
FROM / JOIN -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMITSELECT is nearly last, which is why an alias defined there cannot be used in
WHERE — it does not exist yet — but can be used in ORDER BY, which comes
after.
FILTER: several counts in one pass
The alternative to running the same query four times with different WHERE
clauses:
SELECT p.city,
count(*) AS all_stays,
count(*) FILTER (WHERE b.status = 'CONFIRMED') AS confirmed,
count(*) FILTER (WHERE b.status = 'CANCELLED') AS cancelled,
sum(b.total) FILTER (WHERE b.status <> 'CANCELLED') AS revenue
FROM bookings b
JOIN properties p ON p.id = b.property_id
GROUP BY p.city
ORDER BY all_stays DESC
LIMIT 5;One scan, four answers. You will also see this written as
sum(CASE WHEN … THEN 1 ELSE 0 END), which works and predates FILTER;
FILTER is standard SQL, reads better, and handles NULL correctly without thinking
about it.
Collecting values instead of counting them
SELECT p.city,
count(*) AS properties,
string_agg(DISTINCT p.property_type, ', ' ORDER BY p.property_type) AS types
FROM properties p
WHERE p.status = 'PUBLISHED'
GROUP BY p.city
ORDER BY properties DESC
LIMIT 5;string_agg joins values with a separator; array_agg gives you an
array; json_agg and jsonb_agg give you JSON, which is how you return a
parent and its children in a single round trip instead of an N+1 query. All four take an
ORDER BY inside the parentheses, and without one the order is undefined.
Medians and percentiles
There is no median(), because a median needs the values sorted and an ordinary
aggregate does not sort. The ordered-set aggregates do, and they take their
ORDER BY in a WITHIN GROUP clause:
SELECT round(avg(total), 2) AS mean,
percentile_cont(0.5) WITHIN GROUP (ORDER BY total)::numeric(10,2) AS median,
percentile_cont(0.95) WITHIN GROUP (ORDER BY total)::numeric(10,2) AS p95,
mode() WITHIN GROUP (ORDER BY nights) AS most_common_nights
FROM bookings
WHERE status = 'CONFIRMED';Report the median rather than the mean whenever a few large values would drag the average
somewhere no real row sits — which is true of nearly every price, duration and response time you
will ever aggregate. percentile_cont interpolates between rows;
percentile_disc returns an actual value from the data. For money, the second one is
usually what you meant.
Subtotals without a second query
SELECT p.country, p.property_type, count(*) AS stays
FROM bookings b
JOIN properties p ON p.id = b.property_id
WHERE p.country IN ('Norway', 'Portugal')
GROUP BY ROLLUP (p.country, p.property_type)
ORDER BY p.country NULLS LAST, p.property_type NULLS LAST;ROLLUP adds a subtotal row per country and a grand total, with NULL in the columns
being totalled over. CUBE gives every combination; GROUPING SETS lets you
name exactly the ones you want. Use GROUPING(column) to tell a genuine NULL from a
subtotal marker.
Grouping is a sort or a hash, and it matters which
Postgres has two ways to group. HashAggregate builds a hash table of groups in
memory and is the faster one; GroupAggregate sorts the rows first and reads the
groups off in order. It picks the sort when the groups will not fit in work_mem, or
when the input arrives sorted already.
Two consequences worth knowing before the EXPLAIN post:
- Group by fewer, narrower columns where you have the choice. Grouping by a city name builds a hash table of sixteen entries; grouping by a UUID builds one entry per row, and then it does not fit.
- An index on the grouping column can remove the sort entirely, because the
rows already arrive in group order. That is why a
GROUP BY statusand aGROUP BY public_idover the same table behave nothing alike.
The count(*) that is slower than you expect
SELECT count(*) FROM bookings;Because of MVCC, Postgres cannot answer that from a counter — it has to check which row versions are visible to your transaction, which means reading the table or a whole index. On 400,000 rows that is milliseconds. On a hundred million it is not, and a "total results" count on a paginated screen becomes the slowest thing on the page.
When an estimate will do, the planner already has one:
SELECT reltuples::bigint AS estimated_rows
FROM pg_class
WHERE relname = 'bookings';It is only as fresh as the last ANALYZE, which is exactly the trade you are making
when the alternative is a full scan on every page load. For a search screen, "about 12,000 results"
is what the user wanted anyway, and it costs nothing.