Aggregation & Grouping — Cheat Sheet
SQL · 5 topics. Download the PDF or the Instagram carousel and share it.
GROUPING SETS
GROUPING SETS lets you define multiple grouping combinations in a single query, avoiding verbose UNION ALL rewrites.
- ✓GROUPING SETS replaces multiple UNION ALL aggregations with a single table scan.
- ✓GROUPING(col) returns 1 when the column is absent from the current grouping combination — use it to distinguish rollup NULLs from data NULLs.
- ✓ROLLUP generates hierarchical subtotals; CUBE generates all combinations — both are syntactic sugar over GROUPING SETS.
- ✓Use a custom GROUPING SETS list when you want a strict subset of what CUBE would produce.
- ✓Supported in PostgreSQL 9.5+, SQL Server 2008+, BigQuery, and Snowflake.
-- Requirement: total order amount grouped by city only,
-- by product category only, and by (city + category) together.
-- Anti-pattern: three scans with UNION ALL
SELECT city, NULL AS category, SUM(o.amount) AS revenue
FROM orders o
JOIN users u ON u.id = o.user_id
GROUP BY u.city
UNION ALL
SELECT NULL, p.category, SUM(o.amount)
FROM orders o
JOIN products p ON p.id = o.product_id
GROUP BY p.category
UNION ALL
SELECT u.city, p.category, SUM(o.amount)
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN products p ON p.id = o.product_id
GROUP BY u.city, p.category;
-- Better: single scan with GROUPING SETS
SELECT
u.city,
p.category,
SUM(o.amount) AS revenue
FROM orders o
JOIN users u ON u.id = o.user_id
JOIN products p ON p.id = o.product_id
GROUP BY GROUPING SETS (
(u.city), -- group 1: by city only
(p.category), -- group 2: by category only
(u.city, p.category) -- group 3: both
);DISTINCT vs GROUP BY
DISTINCT and GROUP BY often produce identical results, but they diverge on ORDER BY interaction, HAVING clauses, and when aggregate functions are involved.
- ✓For pure deduplication with no aggregates, DISTINCT and GROUP BY produce the same query plan in most databases.
- ✓HAVING filters groups and is only valid with GROUP BY — you cannot use HAVING with DISTINCT.
- ✓ORDER BY a non-selected column is valid after GROUP BY but restricted after DISTINCT in PostgreSQL.
- ✓GROUP BY can exploit a sorted B-tree index for a streaming GroupAggregate; DISTINCT may require an extra Sort step.
- ✓Prefer GROUP BY over DISTINCT when the query may later grow aggregates or HAVING clauses.
-- Equivalent: list distinct cities where we have users SELECT DISTINCT city FROM users; -- Same plan as: SELECT city FROM users GROUP BY city; -- EXPLAIN (PostgreSQL) — both produce: -- HashAggregate (cost=310.00..320.00 rows=1000) -- Group Key: city -- -> Seq Scan on users -- Rule of thumb: if you need only deduplication, DISTINCT is fine. -- If you need aggregates, counts, or HAVING, use GROUP BY.
COUNT(DISTINCT col) & Approximate Counting
COUNT(DISTINCT col) counts unique non-NULL values but is expensive on large tables; approximate algorithms like HyperLogLog offer near-linear performance with small, configurable error.
- ✓COUNT(DISTINCT col) ignores NULL values — always verify this matches your business intent.
- ✓There is no native COUNT(DISTINCT a, b) syntax; use a subquery with SELECT DISTINCT or hash the combination.
- ✓On large tables, exact COUNT(DISTINCT) requires a full hash-set build — expensive in memory and time.
- ✓HyperLogLog (HLL) approximates cardinality in constant memory with ~0.8–2% error — ideal for analytics.
- ✓PostgreSQL: use the pg_hll extension. BigQuery/Snowflake: APPROX_COUNT_DISTINCT is built in.
- ✓Use exact counting for financial or compliance queries; approximate counting for dashboards and reporting.
-- NULL behaviour
-- Suppose orders.product_id has some NULLs:
INSERT INTO orders (user_id, product_id, amount, status, created_at)
VALUES (1, NULL, 50.00, 'pending', NOW());
SELECT
COUNT(*) AS total_rows, -- counts NULLs too
COUNT(product_id) AS non_null_products, -- excludes NULLs
COUNT(DISTINCT product_id) AS distinct_products -- distinct non-NULLs only
FROM orders;
-- total_rows=1001, non_null_products=1000, distinct_products=maybe 200
-- Counting distinct (user_id, product_id) combinations — no native syntax:
-- Option 1: subquery dedup then count
SELECT COUNT(*) AS distinct_combos
FROM (
SELECT DISTINCT user_id, product_id
FROM orders
WHERE product_id IS NOT NULL
) sub;
-- Option 2: hash concatenation (less safe — collision risk for short values)
SELECT COUNT(DISTINCT CONCAT(user_id, '-', product_id)) AS distinct_combos
FROM orders;HAVING vs WHERE
WHERE filters individual rows before grouping; HAVING filters groups after aggregation — only HAVING can use aggregate functions as filter conditions.
- ✓WHERE executes before GROUP BY (row-level filter); HAVING executes after GROUP BY (group-level filter).
- ✓Aggregate functions (AVG, COUNT, SUM) are only valid in HAVING or SELECT — never in WHERE.
- ✓Push all non-aggregate conditions to WHERE to reduce the number of rows the GROUP BY engine processes.
- ✓Referencing a SELECT alias in HAVING is invalid in most databases because HAVING precedes SELECT in logical order.
- ✓HAVING without GROUP BY filters the single implicit group formed by aggregating all rows.
- ✓Logical execution order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY.
-- Goal: find departments where active employees' average salary exceeds 70 000 -- WRONG: using HAVING to filter non-aggregated conditions (status check) SELECT department_id, AVG(salary) AS avg_sal FROM employees GROUP BY department_id HAVING AVG(salary) > 70000 AND status = 'active'; -- ERROR or logical mistake: status is a row-level filter -- In PostgreSQL this actually raises an error because status is not in GROUP BY. -- CORRECT: WHERE eliminates inactive rows first, HAVING filters groups SELECT department_id, AVG(salary) AS avg_sal FROM employees WHERE status = 'active' -- row-level filter: runs BEFORE GROUP BY GROUP BY department_id HAVING AVG(salary) > 70000; -- group-level filter: runs AFTER GROUP BY -- The WHERE version is also faster: fewer rows enter the GROUP BY.
COUNT, SUM, AVG, MIN, MAX Deep Dive
SQL aggregate functions collapse multiple rows into a single value, but NULL handling, floating-point precision in AVG, and conditional aggregation are frequent sources of subtle bugs.
- ✓COUNT(*) counts all rows; COUNT(col) counts only non-NULL values — the difference matters when NULLs are present.
- ✓SUM, AVG, MIN, and MAX all ignore NULL values; SUM of an all-NULL set returns NULL, not zero — use COALESCE(SUM(...), 0) defensively.
- ✓AVG divides by the count of non-NULL values, which can be misleading if nullability is meaningful in the domain.
- ✓Conditional aggregation (CASE WHEN or FILTER) pivots row data into columns in a single scan without subqueries.
- ✓COUNT(DISTINCT col) deduplicates before counting but can be slow on large tables — consider HyperLogLog extensions for approximations.
- ✓LEFT JOIN before aggregation preserves products/users with zero orders in the result; INNER JOIN excludes them.
-- Demonstrate NULL behaviour with orders (some amounts may be NULL)
SELECT
COUNT(*) AS total_rows, -- includes rows where amount IS NULL
COUNT(amount) AS non_null_amounts, -- skips NULL amounts
COUNT(DISTINCT user_id) AS distinct_users, -- distinct non-NULL user_ids
SUM(amount) AS total_revenue, -- NULL rows ignored; result is NULL if ALL are NULL
AVG(amount) AS avg_amount, -- divides by COUNT(amount), not COUNT(*)
COALESCE(SUM(amount), 0) AS safe_total -- convert NULL result to 0
FROM orders;
-- Gotcha: average differs when NULLs are present
-- If 10 rows exist, 3 have NULL amount, 7 have values:
-- AVG(amount) = SUM of 7 values / 7 (NOT / 10)
-- This may be correct or incorrect depending on business logic
-- AVG precision: use ROUND + CAST for money
SELECT ROUND(AVG(salary::NUMERIC), 2) AS avg_salary FROM employees;