Cheat SheetsSQLAggregation & Grouping

Aggregation & Grouping — Cheat Sheet

SQL · 5 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Aggregation & Grouping
SQL5 topicsQuick revision reference
1

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.
SQL — GROUPING SETS vs UNION ALL, orders + users + products
-- 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
);
2

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.
SQL — DISTINCT vs GROUP BY plan equivalence
-- 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.
3

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.
SQL — NULL behaviour, multi-column distinct count workarounds
-- 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;
4

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.
SQL — WHERE before GROUP BY, HAVING after GROUP 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.
5

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.
SQL — NULL handling in COUNT, SUM, AVG
-- 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;
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/sql