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

Aggregate functions: COUNT, SUM, FILTER, and the traps

COUNT(*) counts rows, COUNT(col) skips NULLs, COUNT(DISTINCT) dedupes. FILTER beats CASE for conditional aggregates, and integer AVG plus SUM overflow are the silent bugs.

SQL Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

“Average order value is $0.” A dashboard quietly showed zero for a week. The cause: AVG(total) where total was an integer column, so Postgres did integer division and floored every fractional cent. The same week, a SUM(amount) on a high-volume int column wrapped past 2.1 billion and threw integer out of range at 3am. Aggregates look trivial; the bugs they hide are not.

The three faces of COUNT

Choosing the wrong COUNT variant is the most common aggregation bug in reporting code — it produces a wrong number without an error. Here’s what each form actually does:

COUNT has three distinct behaviors that beginners conflate — and the difference changes the answer:

  • COUNT(*) — counts rows, period. NULLs included; it never inspects column values.
  • COUNT(col) — counts rows where col is not NULL. It silently skips NULLs.
  • COUNT(DISTINCT col) — counts distinct non-NULL values of col.
SELECT
  COUNT(*)               AS total_orders,      -- every row
  COUNT(discount_code)   AS with_discount,     -- rows where code is not NULL
  COUNT(DISTINCT user_id) AS distinct_buyers   -- unique users
FROM orders;

If 1,000 orders exist but only 200 have a discount_code, COUNT(*) is 1000 and COUNT(discount_code) is 200. Reaching for COUNT(some_column) when you meant “all rows” is a top-5 reporting bug — a single NULL in that column silently undercounts.

FILTER: conditional aggregates without CASE

You often want “count of paid orders” and “count of cancelled orders” in the same row. The old trick is CASE inside the aggregate; the modern, clearer way is the FILTER clause:

SELECT
  COUNT(*) FILTER (WHERE status = 'paid')      AS paid,
  COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
  SUM(total) FILTER (WHERE status = 'paid')    AS paid_revenue
FROM orders;

FILTER (WHERE …) restricts which rows that one aggregate sees, independently per aggregate, without touching the query’s WHERE. It reads cleaner than SUM(CASE WHEN status='paid' THEN total END) and — crucially — COUNT(*) FILTER (WHERE …) counts matching rows, whereas COUNT(CASE WHEN … THEN 1 END) works only because the non-matching branch returns NULL (a subtlety that bites people who write THEN 0, which counts everything). FILTER is standard SQL and is the senior default for conditional aggregation. (You’ll build full pivot tables with it in lesson 05.)

The performance angle is that all three FILTER aggregates above compute in one pass over the data. The alternative juniors reach for — three separate queries (WHERE status='paid', then WHERE status='cancelled', …) UNIONed or run sequentially — scans the table three times. On a 12M-row orders table that’s ~2.4s (3 × ~800ms Seq Scan) versus ~850ms for the single-scan FILTER form: the engine reads each row once and routes it to whichever aggregates’ predicates it satisfies. A separate trap is COUNT(DISTINCT col): unlike a plain COUNT, it can’t be a streaming counter — it must materialize and dedupe every value, so on a high-cardinality column it builds a sort or hash of all distinct values and is the part of the query that spills to disk first. COUNT(*) FILTER stays a cheap counter; COUNT(DISTINCT …) is the expensive cousin to watch in EXPLAIN ANALYZE.

Ordered aggregates: string_agg / array_agg

Some aggregates care about order. string_agg and array_agg concatenate the group’s values, and you can specify the order inside the aggregate:

SELECT user_id,
       string_agg(status, ',' ORDER BY created_at) AS status_timeline,
       array_agg(id ORDER BY created_at DESC)      AS recent_first
FROM orders
GROUP BY user_id;

Without ORDER BY inside the aggregate, the concatenation order is undefined — it depends on the plan and can change between runs. If order matters (a timeline, a CSV), always specify it inside the parentheses, not in the query’s outer ORDER BY (which only sorts the final rows, not the values inside each aggregate).

The numeric traps

Two aggregate bugs are silent and expensive:

Integer AVG. AVG of an integer column returns numeric in Postgres (good), but SUM/division you write by hand on integers truncates. The classic is SUM(total)/COUNT(*) on int columns — that’s integer division, dropping the fraction. Cast first: SUM(total)::numeric / COUNT(*) or just use AVG(total).

SUM overflow. SUM over an integer column returns bigint (safe to ~9.2×10^18), but SUM over a bigint column returns numeric — and SUM over a column that’s int with billions of large rows can still overflow if you cast back. The real-world failure: summing a money column stored as int cents across a busy table eventually trips integer out of range. Defend by storing money as numeric (or bigint cents) and, when in doubt, cast inside the SUM: SUM(amount::bigint).

Why this works

Why does Postgres widen SUM(int) to bigint automatically but not protect every case? Because the result type is chosen from the input type, not the runtime magnitude — the planner can’t know your row count in advance. SUM(int)bigint covers almost everything, but SUM(bigint)numeric (arbitrary precision, slower) is the safety net for truly huge totals. The lesson: pick the storage type for the sum you’ll compute, not just the individual value. A per-row int is fine; a yearly SUM of millions of them may need bigint or numeric.

Quiz

orders has 1000 rows; 200 have a non-NULL discount_code. What does COUNT(discount_code) return?

Quiz

Why prefer COUNT(*) FILTER (WHERE status='paid') over COUNT(CASE WHEN status='paid' THEN 0 END)?

Complete the analogy

Fill in the blank: to make string_agg or array_agg produce values in a guaranteed order, put the ORDER BY _______ the aggregate's parentheses, not in the query's outer ORDER BY.

Recall before you leave
  1. 01
    Distinguish COUNT(*), COUNT(col), and COUNT(DISTINCT col) with one example.
  2. 02
    What does FILTER (WHERE …) do, and why is it better than CASE for conditional aggregation?
  3. 03
    Name the two silent numeric aggregate traps and how to avoid each.
Recap

Aggregate functions look simple and hide sharp edges. COUNT(*) counts rows; COUNT(col) silently skips NULLs; COUNT(DISTINCT col) counts unique non-NULL values — confusing them is a classic reporting bug. The FILTER (WHERE …) clause is the modern, standard way to do conditional aggregation: it restricts which rows each aggregate sees independently, reading more clearly than CASE and dodging the THEN 0 trap. When concatenation order matters, string_agg and array_agg take their own ORDER BY inside the parentheses — the outer ORDER BY won’t help. Finally, two numeric traps: integer division truncates (cast to numeric or use AVG), and SUM over int can overflow on high-volume tables (store money as numeric/bigint). Now when you see a reporting number that’s subtly wrong — off by a constant factor, or mysteriously zero — your first question should be which COUNT variant was used and whether money lived in an int column. The next lesson scales aggregation up: GROUPING SETS, ROLLUP, and CUBE compute many groupings — subtotals and grand totals — in a single pass.

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 6 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.