Skip to content
Advanced Grouping
step 1/4

Reading — step 1 of 4

Learn

~3 min readTransactions, Grouping, NULL

Beyond plain GROUP BY, SQL has tools for multi-level aggregates and subtotals.

GROUPING SETS

Multiple groupings in one query — Postgres, SQL Server, BigQuery support it. SQLite does not yet, but UNION ALL is equivalent.

-- Equivalent to GROUP BY country, AND GROUP BY city, AND total
SELECT country, city, SUM(sales)
FROM sales
GROUP BY GROUPING SETS ((country), (city), ());

Gets you per-country totals, per-city totals, AND a grand total — all in one query.

In SQLite, do it explicitly:

SELECT country, NULL AS city, SUM(sales) FROM sales GROUP BY country
UNION ALL
SELECT NULL, city, SUM(sales) FROM sales GROUP BY city
UNION ALL
SELECT NULL, NULL, SUM(sales) FROM sales;

ROLLUP

Hierarchical subtotals — common in financial reports.

SELECT year, quarter, SUM(sales)
FROM sales
GROUP BY ROLLUP (year, quarter);
-- Per (year, quarter), per year, and a grand total.

Gives you: each year/quarter row + each year subtotal + grand total. The hierarchy collapses from finest to coarsest.

CUBE

All possible combinations of grouping columns:

SELECT region, channel, SUM(sales)
FROM sales
GROUP BY CUBE (region, channel);
-- (region, channel) + (region) + (channel) + ()

Useful for OLAP-style reporting where you want every breakdown.

DISTINCT vs GROUP BY

SELECT DISTINCT country FROM users;
-- Same as:
SELECT country FROM users GROUP BY country;

For cardinality, both work. GROUP BY is the right tool when you also want to aggregate. DISTINCT reads more naturally for "unique values of".

HAVING — filter aggregates

WHERE filters rows BEFORE grouping. HAVING filters AFTER:

SELECT country, COUNT(*) AS user_count
FROM users
WHERE active = 1                         -- before grouping
GROUP BY country
HAVING COUNT(*) > 10;                    -- after grouping

Use HAVING only for conditions on aggregates. Conditions on raw columns belong in WHERE (cheaper, can use indexes).

DISTINCT inside aggregates

SELECT COUNT(*) FROM logins;             -- total logins
SELECT COUNT(DISTINCT user_id) FROM logins;  -- unique users
SELECT AVG(DISTINCT salary) FROM emps;   -- average of unique salaries

COUNT(DISTINCT col) is by far the most common — "how many unique X".

FILTER (Postgres, SQLite 3.30+)

Conditional aggregates:

SELECT
    COUNT(*) AS total,
    COUNT(*) FILTER (WHERE status = 'active') AS active_count,
    COUNT(*) FILTER (WHERE status = 'closed') AS closed_count
FROM users;

Much cleaner than:

SELECT
    COUNT(*) AS total,
    SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_count
FROM users;

But both work. The CASE form is portable; FILTER is more readable.

Pivot patterns

No SQL standard pivot — but you can fake it with conditional aggregates:

SELECT
    product,
    SUM(CASE WHEN quarter = 'Q1' THEN sales END) AS q1,
    SUM(CASE WHEN quarter = 'Q2' THEN sales END) AS q2,
    SUM(CASE WHEN quarter = 'Q3' THEN sales END) AS q3,
    SUM(CASE WHEN quarter = 'Q4' THEN sales END) AS q4
FROM sales
GROUP BY product;

For real pivot tables: tools like crosstab (Postgres tablefunc extension) or pandas/dplyr in the analytical layer.

Tip: order of clauses

The canonical order in writing SQL:

SELECT ... 
FROM ...
WHERE ...           -- filter rows
GROUP BY ...        -- form groups
HAVING ...          -- filter groups
ORDER BY ...        -- sort
LIMIT ...           -- cap

Logical order of evaluation is different (FROM > WHERE > GROUP BY > HAVING > SELECT > ORDER BY > LIMIT) — that's why you can't reference SELECT aliases in WHERE. They're not computed yet.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…

Advanced Grouping — SQL Intermediate