Hook: Interviewers love this question because real databases rarely fail on syntax; they fail when a harmless-looking summary suddenly scans millions of rows.
Question: What does aggregate performance mean in SQL, and how do you make GROUP BY and summary queries faster?
Answer: Aggregate performance is about how quickly SQL can compute totals, counts, averages, and grouped summaries. The biggest wins usually come from reducing rows early with WHERE, grouping on a useful index or partition key, and avoiding repeated scans of the same large table. If the same summary is read often, precomputing it in a summary table or materialized view is often faster than recalculating it every time.
Interview-Ready Answer: I think about aggregate performance as a question of how many rows the database has to touch and whether it can keep the grouping in memory. My first move is to filter early with WHERE, because HAVING does not reduce input before grouping. Then I look for an index or partition key that matches the filter or group key, and if the same summary is queried often, I precompute it with a summary table or materialized view so the database does less work at read time.
In SQL, an aggregate is a function that rolls many rows into a smaller result, like COUNT, SUM, AVG, MIN, or MAX. Performance matters because the database may need to read every matching row, build groups, and sometimes sort or hash those groups. The key idea is simple: fewer rows in, less work out.
WHERE clause removes rows before aggregation starts, so it is the best place to cut work.| Strategy | Best when | Tradeoff |
|---|---|---|
| Hash aggregate | Input is unsorted | Fast, but memory hungry |
| Sort aggregate | Input is already ordered | Sort cost can be high |
| Precomputed summary | Same report is reused often | Extra write and refresh work |
O(n) because the engine must look at each matching row.O(n) time, with memory roughly tied to the number of groups.O(n log n) if a sort is required.work_mem defaults to 4MB; each sort or hash step gets its own budget, so complex queries can spill sooner than you expect.COUNT(*) still has to visit every visible row, COUNT(column) ignores NULL, and COUNT(DISTINCT ...) is usually more expensive because the engine must deduplicate values.SUM returns NULL when there are no matching non-NULL rows, so use COALESCE if you need zero.HAVING filters after grouping, so it does not save the cost of grouping rows you later discard.Imagine a checkout service for an e-commerce site. The product team wants a dashboard showing revenue by day and customer segment. A developer writes a query that groups the entire orders table every time the dashboard loads. It works on a test database, but in production the table has hundreds of millions of rows, so the query spills to temp files, the page takes seconds to load, and the database CPU spikes.
What the team sees: slow dashboard requests, temp file growth, and sometimes log lines about hash aggregation or disk spill. Users complain that charts are blank or lag behind by minutes. The fix is to add a date filter, make sure the filter and group keys are index-friendly, and move the most common totals into a daily summary table or materialized view. That turns a repeated expensive scan into a cheap lookup.
-- PostgreSQL example: aggregation performance basics, null semantics, and a reusable summary.
DROP TABLE IF EXISTS daily_customer_revenue;
DROP TABLE IF EXISTS order_items;
CREATE TABLE order_items (
order_id integer PRIMARY KEY,
customer_id integer NOT NULL,
created_at date NOT NULL,
amount numeric(10,2),
coupon_code text
);
INSERT INTO order_items (order_id, customer_id, created_at, amount, coupon_code) VALUES
(1, 10, DATE '2024-01-01', 25.00, NULL),
(2, 10, DATE '2024-01-01', 40.00, 'SAVE10'),
(3, 11, DATE '2024-01-02', 15.00, NULL),
(4, 11, DATE '2024-01-02', NULL, 'SAVE10'), -- edge case: missing amount should not break the query
(5, 12, DATE '2024-01-03', 60.00, NULL),
(6, 12, DATE '2024-01-03', 18.00, 'SAVE10');
-- Helpful when the query filters by date; the optimizer may be able to read fewer rows.
CREATE INDEX idx_order_items_created_at ON order_items (created_at);
CREATE INDEX idx_order_items_customer_created_at ON order_items (customer_id, created_at);
-- Good pattern: filter first, then group.
-- EXPLAIN is PostgreSQL-specific, but it is the standard way to inspect whether the plan spills or scans too much.
EXPLAIN (ANALYZE, BUFFERS)
SELECT
customer_id,
COUNT(*) AS orders,
COUNT(coupon_code) AS coupon_uses,
COALESCE(SUM(amount), 0) AS revenue
FROM order_items
WHERE created_at >= DATE '2024-01-02'
GROUP BY customer_id
ORDER BY revenue DESC;
-- Edge case: no matching rows.
-- COUNT(*) becomes 0, while SUM(amount) would be NULL without COALESCE.
SELECT
COUNT(*) AS rows_seen,
COALESCE(SUM(amount), 0) AS revenue
FROM order_items
WHERE created_at >= DATE '2030-01-01';
-- COUNT(column) ignores NULL, which is often exactly what you want for quality checks.
SELECT
COUNT(*) AS total_rows,
COUNT(amount) AS rows_with_amount,
COUNT(coupon_code) AS rows_with_coupon
FROM order_items;
-- Repeated reporting is often cheaper from a precomputed summary table.
CREATE TABLE daily_customer_revenue AS
SELECT
created_at,
customer_id,
COUNT(*) AS orders,
COALESCE(SUM(amount), 0) AS revenue
FROM order_items
GROUP BY created_at, customer_id;
SELECT *
FROM daily_customer_revenue
ORDER BY created_at, customer_id;Follow-up & Tricky Questions:
WHERE and HAVING differ for performance? WHERE removes rows before grouping, so it reduces work. HAVING filters after aggregation, so it cannot save the cost of building groups you later discard.GROUP BY? It helps most when the filter or group key matches the index order, or when the database can read rows already sorted. That can reduce both the number of rows scanned and the cost of sorting.COUNT(DISTINCT ...) slower? The engine must deduplicate values, which means extra memory, extra sorting, or both. It is much closer to building a set than to simply counting rows.SUM return 0 on no rows? No. SUM returns NULL when there are no matching non-NULL values, so use COALESCE if the business wants zero.HAVING replace WHERE? Not for row reduction. HAVING runs after grouping, so it is usually slower for simple filters and should not be used as a substitute for WHERE.COUNT(*) avoid touching every row? Sometimes a planner can use an index-oriented path for a narrow filtered query, but there is no magic shortcut for the general case; the engine still has to account for all qualifying visible rows.Common Mistakes:
HAVING instead of WHERE. Correction: use WHERE whenever the condition does not depend on the aggregate result.NULL behavior. Correction: remember that COUNT(column) skips NULL and SUM can return NULL on empty input.Memory Hook: Filter first, group second, sort last — like cleaning a pantry: throw away junk before you count jars, and only line up the jars if you truly need to.
Cheat Sheet:
WHERE shrinks input; HAVING trims grouped output.COALESCE when empty aggregates must show zero instead of NULL.Practice Tasks:
COUNT(*) and COUNT(amount) after inserting a few NULL values.(created_at, customer_id), run EXPLAIN, and see whether the plan changes.