1. What Is It?
Aggregation collapses multiple rows into a single summary value — a count, sum, average, minimum, or maximum. GROUP BY divides the result set into groups first, then aggregates each group independently. HAVING filters those groups after aggregation, the same way WHERE filters rows before aggregation.
These are the primitives behind analytics dashboards, reporting endpoints, and any "how many / how much / what's the top N" query your application runs.
A backend API endpoint needs to return only product categories where the total revenue exceeds $10,000. Which SQL clauses should be used together to implement this correctly?
2. How It Works
-- Basic aggregate: count all paid orders
SELECT COUNT(*) AS paid_order_count
FROM orders
WHERE status = 'paid';
-- GROUP BY: aggregate per group
SELECT
user_id,
COUNT(*) AS order_count,
SUM(total_cents) AS total_spent_cents,
AVG(total_cents) AS avg_order_cents,
MAX(placed_at) AS last_order_at
FROM orders
WHERE status = 'paid'
GROUP BY user_id;
-- One result row per unique user_idThe rule: In a SELECT that uses GROUP BY, every column in the SELECT list must meet one of three conditions. It must be in the GROUP BY clause, be wrapped in an aggregate function, or depend functionally on a grouped primary key. The database raises an error otherwise because a non-grouped, non-aggregated column is ambiguous — if a user has 5 orders, which order's placed_at do you want?
HAVING filters after aggregation:
-- Users who spent more than $500 total
SELECT
user_id,
SUM(total_cents) AS total_spent_cents
FROM orders
WHERE status = 'paid'
GROUP BY user_id
HAVING SUM(total_cents) > 50000; -- 50000 cents = $500Execution order:
- FROM / JOIN — identify source rows
- WHERE — filter individual rows before grouping
- GROUP BY — divide into groups
- HAVING — filter groups
- SELECT — compute output columns
- ORDER BY — sort output
- LIMIT / OFFSET — truncate output
Common aggregate functions:
| Function | Returns |
|---|---|
COUNT(*) | Row count including NULLs |
COUNT(col) | Row count excluding NULL values in col |
COUNT(DISTINCT col) | Count of distinct non-NULL values |
SUM(col) | Total of numeric values (NULL rows skipped) |
AVG(col) | Mean of numeric values (NULL rows skipped) |
MIN(col) | Minimum value |
MAX(col) | Maximum value |
STRING_AGG(col, sep) | Concatenate values with a separator (PostgreSQL) |
ARRAY_AGG(col) | Aggregate values into an array (PostgreSQL) |
Filter rows, then filter user totals
WHERE keeps paid orders before GROUP BY partitions them by user; HAVING then keeps only groups whose total exceeds 50000 cents.
The query starts with source rows from the orders table.
Text version
- The query starts with source rows from the orders table.
- WHERE keeps only paid orders, so other rows never reach the groups.
- GROUP BY puts rows with the same user identifier into one group.
- SUM adds the total cents within each user group.
- HAVING keeps groups whose paid spending exceeds fifty thousand cents, or five hundred dollars.
- SELECT returns each surviving user and the total spent in cents.
A backend developer writes the following query to find the most recent order date per user, but the database raises an error:
SELECT user_id, status, MAX(placed_at) AS last_order_at
FROM orders
GROUP BY user_id;
Why does this query fail?
3. What SDEs Actually Need to Know
COUNT(*) vs. COUNT(col) vs. COUNT(DISTINCT col)
-- COUNT(*): count all rows, including those with NULLs
SELECT COUNT(*) FROM orders; -- total rows in table
-- COUNT(col): count rows where col is NOT NULL
SELECT COUNT(coupon_code) FROM orders; -- only orders that used a coupon
-- COUNT(DISTINCT col): count unique non-NULL values
SELECT COUNT(DISTINCT user_id) FROM orders; -- unique customers who orderedMixing these up is a frequent bug source. If COUNT(DISTINCT col) returns a much smaller number than expected, check whether the column has unexpected NULLs.
NULL in aggregates: SUM, AVG, MIN, MAX skip NULL values silently. SUM of a column that's all NULL returns NULL, not 0. Use COALESCE(SUM(col), 0) if you need 0 for empty groups.
WHERE vs. HAVING:
WHEREruns before grouping — use it to filter source rows (e.g., only paid orders)HAVINGruns after grouping — use it to filter groups based on aggregate values (e.g., users with more than 5 orders)- Using
HAVINGwhereWHEREwould do is slower because it aggregates all rows first, then discards groups
GROUP BY column position (avoid it):
-- Valid but fragile: GROUP BY 1 means GROUP BY the first SELECT column
SELECT user_id, COUNT(*) FROM orders GROUP BY 1;
-- Better: name the column explicitly
SELECT user_id, COUNT(*) FROM orders GROUP BY user_id;Column positions shift when you add or reorder SELECT columns. Name your GROUP BY columns explicitly.
GROUP BY multiple columns: Groups are defined by the unique combination of all grouped columns.
-- Orders per user per status
SELECT user_id, status, COUNT(*)
FROM orders
GROUP BY user_id, status;
-- One row per (user_id, status) pairChoose what COUNT counts
COUNT(*) counts rows, COUNT(col) counts non-NULL values, and COUNT(DISTINCT col) counts unique non-NULL values.
COUNT can count all rows, non-null values, or unique non-null values.
Text version
- COUNT can count all rows, non-null values, or unique non-null values.
- COUNT star still counts a row when the chosen column is null, but the other two forms skip it.
- COUNT distinct counts each non-null user identifier once, so repeat orders do not count as extra customers.
A backend query is supposed to filter out unpaid orders before computing per-user order counts, and then return only users with more than 3 orders. A developer writes the query using HAVING for both filters. What is the problem with this approach?
4. Tradeoffs & Decisions
Aggregating in SQL vs. aggregating in application code
It's tempting to fetch all rows and aggregate in your application:
orders = db.query("SELECT * FROM orders WHERE user_id = ?", user_id)
total = sum(o.total_cents for o in orders)At small scale this works. At scale, the database transfers potentially millions of rows to your application just to compute a single number. Move aggregation into SQL — the database computes it on-disk or in-memory, transfers one row back, and uses indexes to skip unneeded data.
Pre-aggregated tables (materialized aggregates)
For dashboards with expensive aggregations over large tables, some teams pre-aggregate into summary tables updated by scheduled jobs:
CREATE TABLE daily_revenue (
date DATE PRIMARY KEY,
total_cents BIGINT NOT NULL
);
-- Populated by a nightly jobThe tradeoff: reads become instant, but data is stale (as old as the last job run) and you need to maintain the aggregation logic separately. Use this when query time exceeds user tolerance and real-time data isn't required.
ROLLUP and CUBE for multi-level subtotals
GROUP BY ROLLUP(a, b) adds subtotal rows at each level of the grouping hierarchy. Useful for reports but rarely needed in application code.
Where the totals get computed
Summing in app code pulls every row, SQL sends back one result row, and a summary table reads stored totals that can be stale.
Totals can be computed in app code, in the SQL query, or ahead of time by a nightly job.
Text version
- Totals can be computed in app code, in the SQL query, or ahead of time by a nightly job.
- App code makes the database ship every matching row, while SQL sends back one result row.
- A summary table reads stored totals fast, but they are only as fresh as the last job run.
- The job also needs its own aggregation logic, so use it when queries are too slow and real-time data is not required.
A dashboard query aggregates revenue across 50 million order rows and takes 30 seconds to run, far exceeding what users will tolerate. The data only needs to be accurate as of the previous night. Which approach best addresses this situation?
5. Interview Cheat Sheet
Key sentences:
- "GROUP BY divides rows into groups; aggregate functions then reduce each group to a single value."
- "Every SELECT column must appear in GROUP BY, be wrapped in an aggregate, or depend on a grouped primary key. Otherwise, which row's value would you return?"
- "HAVING filters groups after aggregation; WHERE filters rows before. Using HAVING instead of WHERE for simple row filters is slower."
- "
COUNT(col)skips NULLs;COUNT(*)does not.COUNT(DISTINCT col)gives you unique non-null values."
Common follow-ups:
Q: What does it mean when SUM returns NULL instead of 0?
A: SUM over an empty set (no rows match the WHERE clause) returns NULL in SQL. Use COALESCE(SUM(col), 0) to convert NULL to 0, especially in LEFT JOIN queries where the aggregated table might have no matching rows.
Q: Can you use a column alias in HAVING?
A: Not in standard SQL or PostgreSQL (MySQL allows it as an extension) — because HAVING is evaluated before SELECT computes aliases (see execution order). You must repeat the expression: HAVING SUM(total_cents) > 50000, not HAVING total_spent > 50000. PostgreSQL allows alias references in GROUP BY (a non-standard extension), but not in HAVING.
Q: How would you find duplicate rows in a table? A: GROUP BY the columns that define uniqueness, then HAVING COUNT(*) > 1:
SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1;