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;
COUNT(*) counts the rows in each group, including rows with NULLs.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.
| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Toys | 3000 |
| 4 | Food | 500 |
| 5 | Toys | 1500 |
| 6 | Books | 2000 |
Note: Books has 3 orders (ids 1, 2, 6), Toys has 2 (ids 3, 5), and Food has 1 (id 4).
Expected output (order_count descending):
| category | order_count |
|---|---|
| Books | 3 |
| Toys | 2 |
| Food | 1 |
The 6 detail rows collapse into 3 rows — one per category group.
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 */
LEGEND
① 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.| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Toys | 3000 |
| 4 | Food | 500 |
| 5 | Toys | 1500 |
| 6 | Books | 2000 |
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(*) 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.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).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.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(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.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.
| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Books | 2000 |
| 4 | Toys | 3000 |
| 5 | Toys | 1500 |
| 6 | Food | 500 |
Note: the Books average = (1200+800+2000)/3 = 1333.33 → 1333 after ROUND.
Expected output (total_amount descending):
| category | order_count | total_amount | avg_amount | max_amount | min_amount |
|---|---|---|---|---|---|
| Toys | 2 | 4500 | 2250 | 3000 | 1500 |
| Books | 3 | 4000 | 1333 | 2000 | 800 |
| Food | 1 | 500 | 500 | 500 | 500 |
Food has only one order, so total = average = max = min = 500.
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 */
LEGEND
① 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.| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Books | 2000 |
| 4 | Toys | 3000 |
| 5 | Toys | 1500 |
| 6 | Food | 500 |
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(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).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(*).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.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.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;
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.
| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 2000 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | cancelled | 1500 |
| 6 | Toys | cancelled | 1000 |
| 7 | Food | completed | 500 |
Note: 5 distinct (category, status) combinations exist in the data.
Expected output (category, status ascending):
| category | status | order_count | total_amount |
|---|---|---|---|
| Books | cancelled | 1 | 2000 |
| Books | completed | 2 | 2000 |
| Food | completed | 1 | 500 |
| Toys | cancelled | 2 | 2500 |
| Toys | completed | 1 | 3000 |
The 7 rows collapse into 5 combination groups.
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 */
LEGEND
① 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).| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 2000 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | cancelled | 1500 |
| 6 | Toys | cancelled | 1000 |
| 7 | Food | completed | 500 |
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.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.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).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)
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.
| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 5000 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | completed | 1500 |
| 6 | Food | completed | 500 |
| 7 | Food | cancelled | 9000 |
Note: the high-value ids 3 (5000) and 7 (9000) are cancelled, so WHERE removes them and they never reach the totals.
Expected output (total_amount descending):
| category | order_count | total_amount |
|---|---|---|
| Toys | 2 | 4500 |
| Books | 2 | 2000 |
Food only has 500 in completed orders, misses the HAVING threshold (2000), and is excluded.
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 */
LEGEND
① 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.| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 5000 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | completed | 1500 |
| 6 | Food | completed | 500 |
| 7 | Food | cancelled | 9000 |
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".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.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.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
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).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.
| order_id | category | customer_id | status |
|---|---|---|---|
| 1 | Books | 101 | completed |
| 2 | Books | 101 | completed |
| 3 | Books | 102 | cancelled |
| 4 | Toys | 103 | completed |
| 5 | Toys | 103 | completed |
| 6 | Toys | 104 | cancelled |
| 7 | Food | 105 | completed |
| 8 | Food | 105 | cancelled |
Note: Books has 3 orders but only 2 customers (101, 101, 102). Completion rate = completed orders / total orders.
Expected output (total_orders descending):
| category | total_orders | unique_customers | completed_orders | completion_pct |
|---|---|---|---|---|
| Books | 3 | 2 | 2 | 66.7 |
| Toys | 3 | 2 | 2 | 66.7 |
| Food | 2 | 1 | 1 | 50.0 |
Books and Toys tie on total_orders (3), so category ascending puts Books first.
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 */
LEGEND
① FROM orders (8 rows)
FROM ordersRead 8 rows. customer_id contains repeats (101 twice, 103 twice, 105 twice), and status mixes completed / cancelled.| order_id | category | customer_id | status |
|---|---|---|---|
| 1 | Books | 101 | completed |
| 2 | Books | 101 | completed |
| 3 | Books | 102 | cancelled |
| 4 | Toys | 103 | completed |
| 5 | Toys | 103 | completed |
| 6 | Toys | 104 | cancelled |
| 7 | Food | 105 | completed |
| 8 | Food | 105 | cancelled |
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.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(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.COUNT(*) (= 3) for the customer count overstates the real 2 people as 3. To count people, always use COUNT(DISTINCT customer_id).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).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.