GROUP BY — Learn Grouping, Aggregation, and Conditional Aggregation from the Basics
BasicGROUP BYAggregate functionsWHERE / HAVINGConditional aggregationPostgreSQL-ready5 questions
QUESTION 1
Grouping Basics — Collapse Rows into Groups with GROUP BY and COUNT(*)
GROUP BYCOUNT(*)Group aggregationRow collapsing
Background

Grouping with GROUP BY is the starting point of aggregation in SQL. Rows that share the same value in the specified column are bundled into one group, and an aggregate function (COUNT, SUM, etc.) collapses each group into a single value. It is the first step from "detail rows" to a "summary".

-- GROUP BY: bundle rows with the same category value into one group
SELECT   category, COUNT(*) AS order_count
FROM     orders
GROUP BY category;
The golden rule of collapsing: once you write GROUP BY, the result is "one row per group". Therefore the SELECT list may only contain the grouping keys (columns listed in GROUP BY) or aggregate functions. COUNT(*) counts the rows in each group, including rows with NULLs.
Problem

From the orders table, compute the number of orders per category. Output columns: category, order_count. Sort by order_count descending, breaking ties by category ascending.

Tables used
► orders (6 rows)
order_idcategoryamount
1Books1200
2Books800
3Toys3000
4Food500
5Toys1500
6Books2000

Note: Books has 3 orders (ids 1, 2, 6), Toys has 2 (ids 3, 5), and Food has 1 (id 4).

Expected Output

Expected output (order_count descending):

categoryorder_count
Books3
Toys2
Food1

The 6 detail rows collapse into 3 rows — one per category group.

Model Answer
SELECT
  category,                -- the grouping key column (allowed in the SELECT list)
  COUNT(*) AS order_count  -- aggregate that counts rows in each group (all rows, including NULLs)
FROM     orders
GROUP BY category          -- bundle rows with the same category value into one group
ORDER BY order_count DESC, category; -- most orders first (descending); ties sorted by category ascending

/*
  Execution order (logical evaluation order in SQL):
  1. FROM orders        → read the rows
  2. GROUP BY category  → group by category
  3. COUNT(*)           → count rows per group
  4. SELECT             → output category, order_count
  5. ORDER BY ... DESC  → sort by count, descending
  */
Explanation (table transitions & key points)
SELECT category, COUNT(*) AS order_count FROM orders GROUP BY category ORDER BY order_count DESC, category;
LEGEND
Rows read / loaded
① FROM orders (6 rows)
FROM ordersRead the 6 rows of the orders table. The category column holds three distinct values — Books / Toys / Food — and the rows are still ungrouped.
1 / 4
order_idcategoryamount
1Books1200
2Books800
3Toys3000
4Food500
5Toys1500
6Books2000
6 rows read (before grouping)
LEARNING POINTS
GROUP BY is a "row-collapsing" operation: after grouping, each group becomes exactly one row. Turning detail (6 rows) into a summary (3 rows) — this collapse is the essence of aggregation. Remember that the number of output rows equals the number of distinct groups, and results become easy to predict.
The SELECT list may only contain "keys" or "aggregates": after collapsing, multiple amount values coexist within a group, so writing a bare non-aggregated column like SELECT category, amount raises an error in PostgreSQL. Only columns listed in GROUP BY, or aggregate functions such as SUM() and COUNT(), are allowed.
COUNT(*) counts every row, NULLs included: COUNT(*) returns the total number of rows in the group. This contrasts with COUNT(column) — covered later — which skips NULLs. When you simply want "how many rows", COUNT(*) is the default choice.
ANTI-PATTERNS
Putting a non-aggregated column in SELECT: SELECT category, order_id, COUNT(*) ... GROUP BY category is an error, because order_id has no single value within a group. Select only columns that resolve to one value per group (= keys or aggregates).
Trying to filter on counts in WHERE: WHERE COUNT(*) > 1 is a syntax error. Filtering groups by aggregate results is HAVING's job (detailed in Q4). WHERE operates only on raw rows, before grouping.
Field Notes: From Detail to Summary — the First Step of Every Report
Sales line items, access logs, order histories — most real-world data comes as detail rows, one row per event. GROUP BY is the first tool that turns that detail into a meaningful summary. Just by switching the slice (the grouping key) — "per category", "per day", "per user" — the same data yields countless reports. From Q2 onward, we layer aggregate functions such as SUM / AVG and multi-column keys onto this collapse, extending its expressive power one step at a time.
QUESTION 2
Aggregate Functions — SUM / AVG / MAX / MIN per Group
SUM / AVGMAX / MINROUNDAggregate functions
Background

Grouping shows its real power when combined with aggregate functions. For each group you can compute the sum, average, maximum, minimum and so on, each as a single value. Multiple aggregates are computed together in one grouping pass.

-- each aggregate returns one value per group
SUM(amount)              -- total
AVG(amount)              -- average (NULLs are ignored)
MAX(amount), MIN(amount) -- max / min
ROUND(AVG(amount), 0)   -- formatting decimal places
AVG and NULL: AVG(amount) includes NULL rows in neither the numerator nor the denominator (they are ignored). Meanwhile COUNT(*) does count NULL rows, so "AVG's denominator" and "COUNT(*)" can diverge. Use ROUND(value, digits) to format averages.
Problem

From the orders table, compute per category: order count, total, average (rounded to an integer), maximum, and minimum. Output columns: category, order_count, total_amount, avg_amount, max_amount, min_amount. Sort by total_amount descending.

Tables used
► orders (6 rows)
order_idcategoryamount
1Books1200
2Books800
3Books2000
4Toys3000
5Toys1500
6Food500

Note: the Books average = (1200+800+2000)/3 = 1333.33 → 1333 after ROUND.

Expected Output

Expected output (total_amount descending):

categoryorder_counttotal_amountavg_amountmax_amountmin_amount
Toys24500225030001500
Books3400013332000800
Food1500500500500

Food has only one order, so total = average = max = min = 500.

Model Answer
SELECT
  category,                             -- the grouping key column
  COUNT(*)             AS order_count,  -- count of all rows in the group
  SUM(amount)          AS total_amount, -- total of amount within the group
  ROUND(AVG(amount), 0) AS avg_amount,  -- average, rounded to 0 decimal places (an integer)
  MAX(amount)          AS max_amount,   -- largest value within the group
  MIN(amount)          AS min_amount    -- smallest value within the group
FROM     orders
GROUP BY category                       -- group per category
ORDER BY total_amount DESC;             -- sort by total, descending

/*
  Execution order (logical evaluation order in SQL):
  1. FROM orders                 → read the rows
  2. GROUP BY category           → group by category
  3. aggregate per group         → COUNT/SUM/AVG/MAX/MIN
  4. ORDER BY total_amount DESC  → sort by total, descending
  */
Explanation (table transitions & key points)
SELECT category, COUNT(*) AS order_count, SUM(amount) AS total_amount, ROUND(AVG(amount), 0) AS avg_amount, MAX(amount) AS max_amount, MIN(amount) AS min_amount FROM orders GROUP BY category ORDER BY total_amount DESC;
LEGEND
Rows read / loaded
① FROM orders (6 rows)
FROM ordersRead the 6 rows of the orders table. This time the goal is to aggregate the numeric amount column per group.
1 / 4
order_idcategoryamount
1Books1200
2Books800
3Books2000
4Toys3000
5Toys1500
6Food500
6 rows read
LEARNING POINTS
Multiple aggregates are computed in a single pass: even with COUNT, SUM, AVG, MAX and MIN side by side, the table is scanned only once. Since you can grab every metric you need in the same grouping pass, this is far more efficient than writing separate queries.
AVG ignores NULLs (and can diverge from COUNT(*)): AVG(amount) excludes NULL rows from the calculation. In other words, the average's denominator is COUNT(amount) (NULLs excluded), which may differ from COUNT(*) (all rows). Always check whether the denominator is the one you intend — it prevents misreading averages.
ROUND tidies up the output: ROUND(AVG(amount), 0) rounds the average to 0 decimal places (an integer). Aligning digits makes reports dramatically easier to read. For percentages, just change the precision, e.g. ROUND(..., 1).
ANTI-PATTERNS
Integer division silently truncates the average: computing SUM(amount) / COUNT(*) on integer types drops the decimals and skews the average. Simply use AVG(), or force decimals with something like SUM(amount) * 1.0 / COUNT(*).
Confusing MAX / MIN with string ordering: on numeric columns, comparisons behave as expected — but MAX() on a string column uses dictionary order ('Z' > 'A'). Ignore the column's type and your "largest order amount" may turn out to be something else entirely.
Field Notes: A KPI Dashboard from a Single Query
"Order count, revenue, average ticket, largest sale" on a sales dashboard — exactly what today's single query delivers. Adding one more aggregate costs almost nothing, so the practical convention is to line up every metric you might need from the start. Add COUNT(DISTINCT ...) and FILTER from Q5 and you also get "unique customers" and "completion rate" in one shot — one query becomes the entire backend of a dashboard.
QUESTION 3
Multi-Column Grouping — GROUP BY Two Columns to Aggregate per Combination
Multi-column GROUP BYSUMComposite groupsPer combination
Background

GROUP BY accepts multiple columns. Groups are then formed from rows whose combination of column values matches. With GROUP BY category, status, (Books, completed) and (Books, cancelled) are separate groups. This is the foundation of cross-tabulation — combining analysis dimensions.

-- group per (category, status) combination
SELECT   category, status, COUNT(*), SUM(amount)
FROM     orders
GROUP BY category, status;
Group count = number of combinations: the number of groups equals the number of (category, status) combinations actually present in the data — not the theoretical maximum. Every non-aggregated column in SELECT — here both category and status — must appear in GROUP BY.
Problem

From the orders table, compute the order count and total amount per category × status combination. Output columns: category, status, order_count, total_amount. Sort by category, then status, ascending.

Tables used
► orders (7 rows)
order_idcategorystatusamount
1Bookscompleted1200
2Bookscompleted800
3Bookscancelled2000
4Toyscompleted3000
5Toyscancelled1500
6Toyscancelled1000
7Foodcompleted500

Note: 5 distinct (category, status) combinations exist in the data.

Expected Output

Expected output (category, status ascending):

categorystatusorder_counttotal_amount
Bookscancelled12000
Bookscompleted22000
Foodcompleted1500
Toyscancelled22500
Toyscompleted13000

The 7 rows collapse into 5 combination groups.

Model Answer
SELECT
  category,
  status,
  COUNT(*)    AS order_count,  -- count per combination
  SUM(amount) AS total_amount  -- total per combination
FROM     orders
GROUP BY category, status      -- group per "combination" of category and status
ORDER BY category, status;     -- multi-column sort (category first, then status, both ascending)

/*
  Execution order (logical evaluation order in SQL):
  1. FROM orders                → read the rows
  2. GROUP BY category, status  → group per combination
  3. COUNT(*) / SUM(amount)     → aggregate per combination
  4. ORDER BY category, status  → sort by name
  */
Explanation (table transitions & key points)
SELECT category, status, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY category, status ORDER BY category, status;
LEGEND
Rows read / loaded
① FROM orders (7 rows)
FROM ordersRead the 7 rows of the orders table. We will aggregate by the combination of category (3 values) and status (completed/cancelled).
1 / 4
order_idcategorystatusamount
1Bookscompleted1200
2Bookscompleted800
3Bookscancelled2000
4Toyscompleted3000
5Toyscancelled1500
6Toyscancelled1000
7Foodcompleted500
7 rows read
LEARNING POINTS
Multi-column keys group by "combination": when you list several columns in GROUP BY, rows with an identical combination of those values form one group. Same category but different status → different group. This is the foundation of cross-dimensional analysis (cross-tabs).
Only combinations that exist become groups: 3 categories × 2 statuses = 6 theoretical combinations, but only 5 appear in the data, so the result has 5 rows. Missing combinations (e.g. Food, cancelled) simply do not appear. To show all combinations zero-filled, you would need a JOIN against a master table.
Every non-aggregated column goes into GROUP BY: if SELECT lists bare category and status, both must appear in GROUP BY. Omit either one and some group ends up with "multiple values per group" — an error. Remember: non-aggregated SELECT columns ⊆ GROUP BY.
ANTI-PATTERNS
Grouping by category only: SELECT category, status ... GROUP BY category is an error (status has no single value within a group). Align the GROUP BY columns with the granularity — the combination — you want in the output.
Assuming column order changes the granularity: GROUP BY category, status and GROUP BY status, category produce exactly the same set of groups (output order is ORDER BY's job). Understanding that GROUP BY column order never changes the meaning of the result avoids much confusion.
Field Notes: From Long-Format Aggregates to a Pivot Table
Today's "category × status" result is a long-format cross-tabulation. To turn it into a pivot table ("rows = category, columns = status"), expand the statuses into columns with conditional aggregation such as SUM(CASE WHEN status='completed' THEN amount ELSE 0 END), as seen in Q2. Build the granularity with multi-column GROUP BY, then open it sideways with conditional aggregates — this two-step move is the classic way to cross-tab in practice (conditional aggregation is covered in Q5).
QUESTION 4
WHERE vs HAVING — Filtering Before and After Grouping
WHEREHAVINGExecution orderPost-aggregation filter
Background

There are two kinds of filters. WHERE narrows the raw rows before grouping; HAVING narrows the groups after grouping and aggregation. When a condition involves an aggregate function, HAVING is the tool. The difference between them is the key to understanding SQL's execution order.

-- evaluation order: FROM → WHERE → GROUP BY → aggregate → HAVING → SELECT → ORDER BY
SELECT   category, SUM(amount)
FROM     orders
WHERE    status = 'completed'   -- ① narrow the rows (before aggregation)
GROUP BY category
HAVING   SUM(amount) >= 2000    -- ② narrow the groups (after aggregation)
How to choose: filtering on raw column values (like status)? Use WHERE. Filtering on aggregate results (SUM, COUNT, ...)? Use HAVING. WHERE runs before aggregation, so it cannot contain aggregate functions; HAVING runs after, so it can.
Problem

From the orders table, considering only completed orders, find the categories whose total amount is at least 2000. Output columns: category, order_count, total_amount. Sort by total_amount descending.

Tables used
► orders (7 rows)
order_idcategorystatusamount
1Bookscompleted1200
2Bookscompleted800
3Bookscancelled5000
4Toyscompleted3000
5Toyscompleted1500
6Foodcompleted500
7Foodcancelled9000

Note: the high-value ids 3 (5000) and 7 (9000) are cancelled, so WHERE removes them and they never reach the totals.

Expected Output

Expected output (total_amount descending):

categoryorder_counttotal_amount
Toys24500
Books22000

Food only has 500 in completed orders, misses the HAVING threshold (2000), and is excluded.

Model Answer
SELECT
  category,
  COUNT(*)    AS order_count,
  SUM(amount) AS total_amount
FROM     orders
WHERE    status = 'completed'  -- [pre-aggregation filter] keep only completed orders, BEFORE grouping
GROUP BY category              -- group per category
HAVING   SUM(amount) >= 2000   -- [post-aggregation filter] keep only groups whose total is 2000 or more, AFTER aggregating
ORDER BY total_amount DESC;    -- sort by total, descending

/*
  Execution order (logical evaluation order in SQL):
  1. FROM orders                 → read the rows
  2. WHERE status='completed'    → keep completed rows
  3. GROUP BY category           → group by category
  4. HAVING SUM(amount)          → keep groups above the threshold
  5. ORDER BY total_amount DESC  → sort by total, descending
  */
Explanation (table transitions & key points)
SELECT category, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE status = 'completed' GROUP BY category HAVING SUM(amount) >= 2000 ORDER BY total_amount DESC;
LEGEND
Rows read / loaded
① FROM orders (7 rows)
FROM ordersRead 7 rows. The cancelled ids 3 (5000) and 7 (9000) are big amounts — but watch how the upcoming WHERE removes them.
1 / 6
order_idcategorystatusamount
1Bookscompleted1200
2Bookscompleted800
3Bookscancelled5000
4Toyscompleted3000
5Toyscompleted1500
6Foodcompleted500
7Foodcancelled9000
7 rows read
LEARNING POINTS
WHERE before aggregation, HAVING after: the execution order is FROM → WHERE → GROUP BY → aggregate → HAVING → SELECT → ORDER BY. WHERE filters raw rows; HAVING filters aggregated groups. Keep this order in mind and you can instantly decide where a condition belongs.
Filtering early in WHERE gets both correctness and speed: dropping the cancelled high-value orders (5000, 9000) in WHERE keeps the totals from being inflated incorrectly. It also shrinks the row set to aggregate, so the query runs lighter. "Filter as early as you can" is the iron rule.
Some conditions can only live in HAVING: a condition on an aggregate result like SUM(amount) >= 2000 belongs exclusively to HAVING. At the WHERE stage, SUM has not been computed yet and cannot be referenced. Bind the idea firmly: "filtering on aggregates = HAVING".
ANTI-PATTERNS
Writing an aggregate in WHERE: WHERE SUM(amount) >= 2000 is a syntax error. Aggregation happens after WHERE, so aggregates are invisible there. Conditions on aggregate results always go to HAVING.
Putting row conditions in HAVING and losing performance: HAVING status = 'completed' (with status not even in GROUP BY) is both wrong and inefficient. Filters on raw column values belong in WHERE, reducing the row count before the grouping happens.
Field Notes: "Filter Early" — the First Step to Faster Queries
On large tables, how many rows WHERE can discard, and how early, largely decides query speed. Indexes also apply at the WHERE stage. HAVING, on the other hand, kicks in after the heavy grouping step, so its row-reduction payoff is limited. In practice, mechanically routing "row-level conditions → WHERE, group-level conditions → HAVING" buys you both correctness and speed. Q5 builds on this foundation with conditional aggregation for even more expressive power.
QUESTION 5
COUNT(DISTINCT) and Conditional Aggregation — Combining Unique Counts, FILTER and NULLIF
COUNT(DISTINCT)FILTERNULLIFConditional aggregation
Background

To round off grouping, we cover COUNT's three faces and conditional aggregation. COUNT(*) counts all rows, COUNT(column) counts non-NULL rows, and COUNT(DISTINCT column) counts unique values. FILTER is syntax that attaches a condition to a single aggregate.

COUNT(*)                              -- all rows
COUNT(DISTINCT customer_id)           -- number of distinct customers
COUNT(*) FILTER (WHERE status = 'completed')  -- matching rows only
... / NULLIF(COUNT(*), 0)             -- avoid division by zero
What the gap between COUNT(*) and COUNT(DISTINCT) tells you: if a group has COUNT(*)=3 and COUNT(DISTINCT customer_id)=2, you can read it as "3 orders placed by 2 people (= one repeat customer)". FILTER counts only the rows matching its condition, giving you concise conditional aggregation without CASE WHEN (PostgreSQL syntax).
Problem

From the orders table, compute per category: total orders, unique customers, completed orders, and completion rate (%). Output columns: category, total_orders, unique_customers, completed_orders, completion_pct. Sort by total_orders descending, breaking ties by category ascending.

Tables used
► orders (8 rows)
order_idcategorycustomer_idstatus
1Books101completed
2Books101completed
3Books102cancelled
4Toys103completed
5Toys103completed
6Toys104cancelled
7Food105completed
8Food105cancelled

Note: Books has 3 orders but only 2 customers (101, 101, 102). Completion rate = completed orders / total orders.

Expected Output

Expected output (total_orders descending):

categorytotal_ordersunique_customerscompleted_orderscompletion_pct
Books32266.7
Toys32266.7
Food21150.0

Books and Toys tie on total_orders (3), so category ascending puts Books first.

Model Answer
SELECT
  category,
  COUNT(*)                                     AS total_orders,     -- [all rows] count every order in the group
  COUNT(DISTINCT customer_id)                  AS unique_customers, -- [unique count] deduplicate customer ids to count people
  COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders, -- [conditional aggregate] count only rows with completed status
  ROUND(
    COUNT(*) FILTER (WHERE status = 'completed') * 100.0            -- multiply by 100.0 to force decimals; this is the percentage numerator
    / NULLIF(COUNT(*), 0), 1)                  AS completion_pct    -- NULLIF guards against division by zero; round to 1 decimal place
FROM     orders
GROUP BY category                                                   -- group per category
ORDER BY total_orders DESC, category;                               -- most orders first; ties sorted by category ascending

/*
  Execution order (logical evaluation order in SQL):
  1. FROM orders                           → read the rows
  2. GROUP BY category                     → group by category
  3. aggregate per group                   → COUNT / DISTINCT customers / completion rate
  4. ORDER BY total_orders DESC, category  → sort by count, descending
  */
Explanation (table transitions & key points)
SELECT category, COUNT(*) AS total_orders, COUNT(DISTINCT customer_id) AS unique_customers, COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders, ROUND( COUNT(*) FILTER (WHERE status = 'completed') * 100.0 / NULLIF(COUNT(*), 0), 1) AS completion_pct FROM orders GROUP BY category ORDER BY total_orders DESC, category;
LEGEND
Rows read / loaded
① FROM orders (8 rows)
FROM ordersRead 8 rows. customer_id contains repeats (101 twice, 103 twice, 105 twice), and status mixes completed / cancelled.
1 / 4
order_idcategorycustomer_idstatus
1Books101completed
2Books101completed
3Books102cancelled
4Toys103completed
5Toys103completed
6Toys104cancelled
7Food105completed
8Food105cancelled
8 rows read
LEARNING POINTS
Use COUNT's three faces deliberately: COUNT(*) = all rows, COUNT(column) = non-NULL rows, COUNT(DISTINCT column) = unique values (NULLs excluded too). Choosing correctly for what you want to count — COUNT(*) for "orders", COUNT(DISTINCT customer_id) for "customers" — determines the accuracy of your aggregates.
FILTER is conditional aggregation, terser than CASE WHEN: COUNT(*) FILTER (WHERE status='completed') expresses "count only completed orders" in a single line. Its power is that within one grouping pass, each metric can carry its own condition — in PostgreSQL it reads better than CASE WHEN and is the first choice.
NULLIF makes ratio calculations safe: / NULLIF(COUNT(*), 0) returns NULL when the denominator is 0, preventing a division-by-zero error. "The completion rate of a category with zero orders" is correctly undefined (NULL), not 0%; combine with ROUND for a readable percentage.
ANTI-PATTERNS
Counting customers with COUNT(*): in Books, which has a repeat purchase, mistaking COUNT(*) (= 3) for the customer count overstates the real 2 people as 3. To count people, always use COUNT(DISTINCT customer_id).
Skipping NULLIF and inviting division by zero: leaving the denominator raw, as in COUNT(*) FILTER(...) * 100.0 / COUNT(*), triggers a division by zero error for groups with no matching rows. In ratio calculations, always wrap the denominator in NULLIF(denominator, 0).
Field Notes: A One-Query Dashboard Built with FILTER
FILTER's real value is in "producing multiple metrics from a single grouping pass". In practice you line up things like SUM(amount) FILTER (WHERE status='completed') (completed revenue) and COUNT(*) FILTER (WHERE amount >= 5000) (high-value orders) in one SELECT and generate a per-category KPI table in one shot. No repeated scans of the same data, with per-condition aggregates laid out side by side — dashboard queries become dramatically more concise. CASE WHEN can express the same thing, but in PostgreSQL, FILTER states the intent more clearly and is easier to maintain — the first choice.