Unique counts — Count distinct values within each group with COUNT(DISTINCT)
The same person may access a site many times — in such data, total events and actual people are different things. Use COUNT(DISTINCT column) to count the number of distinct values within a group. The difference between COUNT(*) (number of rows), COUNT(column) (non-NULL values), and COUNT(DISTINCT column) is the key.
-- Compare three kinds of COUNT COUNT(*) -- Number of rows (all rows, including NULLs) COUNT(user_id) -- Number of rows where user_id is not NULL COUNT(DISTINCT user_id) -- Number of distinct user_id values (duplicates collapse to one)
COUNT(DISTINCT user_id) returns the number of distinct user_id values within each group. Even when total accesses (COUNT(*)) are the same, frequent revisits by the same users make the number of actual people (COUNT(DISTINCT user_id)) smaller. NULL is not counted.From the page_views table, calculate the daily total view count and unique visitor count. Output columns: view_date, total_views, unique_users. Return rows in ascending view_date order.
| view_id | view_date | user_id |
|---|---|---|
| 1 | 2024-05-01 | U1 |
| 2 | 2024-05-01 | U1 |
| 3 | 2024-05-01 | U2 |
| 4 | 2024-05-02 | U2 |
| 5 | 2024-05-02 | U3 |
| 6 | 2024-05-02 | U3 |
| 7 | 2024-05-02 | U1 |
| view_date | total_views | unique_users |
|---|---|---|
| 2024-05-01 | 3 | 2 |
| 2024-05-02 | 4 | 3 |
Conditional aggregation — Pivot statuses into columns with FILTER
Want to put counts for each status on one row? A pivot (wide format) expands the vertically stacked status values into columns. With conditional aggregation, you can calculate multiple metrics in one table scan. In PostgreSQL, the aggregate FILTER (WHERE …) syntax is an elegant way to do this.
-- FILTER is a WHERE that applies only to one aggregate COUNT(*) FILTER (WHERE status = 'paid') -- Count only paid rows SUM(amount) FILTER (WHERE status = 'paid') -- Sum only paid amounts -- For portability, CASE can express the same logic SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END)
FILTER, on the other hand, applies its condition to only that one aggregate function. That is why “total count” and “paid-only count” can appear on the same row. When no rows match, COUNT returns 0 while SUM returns NULL.From the orders table, produce one row per category containing the total order count, counts by status, and the total amount for paid orders. Output columns: category, total_orders, paid_orders, pending_orders, cancelled_orders, paid_amount. Sort by total_orders descending, then category ascending for ties.
| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | paid | 1200 |
| 2 | Books | paid | 800 |
| 3 | Books | paid | 600 |
| 4 | Books | pending | 500 |
| 5 | Books | cancelled | 300 |
| 6 | Toys | paid | 2000 |
| 7 | Toys | cancelled | 1500 |
| 8 | Toys | pending | 700 |
| category | total_orders | paid_orders | pending_orders | cancelled_orders | paid_amount |
|---|---|---|---|---|---|
| Books | 5 | 3 | 1 | 1 | 2600 |
| Toys | 3 | 1 | 1 | 1 | 2000 |
Subtotals and grand totals — Produce summary rows at once with ROLLUP
When you want regional subtotals and an overall grand total alongside detail aggregates, use GROUP BY ROLLUP(…). It automatically adds higher-level summary rows to ordinary grouping. GROUPING() tells you whether a row is a summary row.
-- ROLLUP(a, b) generates these three aggregation levels at once GROUP BY ROLLUP(region, product) -- (region, product) … detail -- (region) … region subtotal (product is collapsed) -- () … grand total (everything is collapsed) GROUPING(product) -- 1 when product is collapsed on the row, 0 for detail
GROUPING(column)=1 (the column was rolled up) and replace it with a label such as '(All)' using CASE.From the sales table, create a table of regional × product sales with a regional subtotal and an overall grand total. Output columns: region, product, total_amount. Show (All) for collapsed columns on subtotal and grand-total rows, and place each summary row at the end of its block (details → subtotal, with the grand total last).
| region | product | amount |
|---|---|---|
| East | Apple | 100 |
| East | Banana | 150 |
| West | Apple | 200 |
| West | Banana | 250 |
| region | product | total_amount |
|---|---|---|
| East | Apple | 100 |
| East | Banana | 150 |
| East | (All) | 250 |
| West | Apple | 200 |
| West | Banana | 250 |
| West | (All) | 450 |
| (All) | (All) | 700 |
Two-stage aggregation — Correctly calculate an average of customer totals with a derived table
You intend to calculate “average purchase amount per customer,” but before you know it you have calculated “average per order” instead — a classic mistake in aggregation grain. To aggregate the result of an aggregation, separate the stages with a subquery (derived table) or CTE. One GROUP BY alone cannot express it.
-- ① First, total each customer (grain = customer) WITH customer_totals AS ( SELECT customer_id, SUM(amount) AS customer_total FROM orders GROUP BY customer_id ) -- ② Average that result (one row = one customer) SELECT AVG(customer_total) FROM customer_totals;
AVG(amount) on raw orders is the average per order. First collapsing to a customer total with GROUP BY customer_id and then applying AVG gives the average per customer. They are different numbers. Always keep “average per what?” in mind.From the orders table, calculate the customer count and average purchase amount per customer by region (first total each customer, then average those totals by region). Output columns: region, customer_count, avg_customer_spend (round the average to an integer). Sort by avg_customer_spend descending, then region ascending for ties.
| order_id | region | customer_id | amount |
|---|---|---|---|
| 1 | East | C1 | 1000 |
| 2 | East | C1 | 2000 |
| 3 | East | C2 | 3000 |
| 4 | West | C3 | 500 |
| 5 | West | C3 | 500 |
| 6 | West | C4 | 2000 |
| region | customer_count | avg_customer_spend |
|---|---|---|
| East | 2 | 3000 |
| West | 2 | 1500 |
Share analysis — Calculate each group’s share of the whole with a window aggregate
To show each group’s total together with “what percentage of the whole it represents,” use the window function SUM(…) OVER (). It keeps the result rows from GROUP BY intact while broadcasting the overall aggregate to every row. You can calculate shares in one statement without a subquery or join.
-- Divide each group total by the grand total of all groups SUM(amount) -- ① After GROUP BY: total for this group SUM(SUM(amount)) OVER () -- ② Sum ① across groups = grand total (same value on every row) 100.0 * SUM(amount) / SUM(SUM(amount)) OVER () -- ③ Share (%)
SUM(amount) is the total for each group after GROUP BY. The outer window SUM(…) OVER () sums across groups and broadcasts the grand total to every row. Because window functions run after aggregation, they can add another aggregation layer over the grouped result. OVER () means that all rows form one window.From the sales table, calculate the total sales by category and each category’s share of the whole (%). Output columns: category, category_total, pct_of_total (round the share to one decimal place). Sort by category_total descending, then category ascending for ties.
| sale_id | category | amount |
|---|---|---|
| 1 | Electronics | 4000 |
| 2 | Electronics | 2000 |
| 3 | Clothing | 1500 |
| 4 | Clothing | 500 |
| 5 | Food | 2000 |
| category | category_total | pct_of_total |
|---|---|---|
| Electronics | 6000 | 60.0 |
| Clothing | 2000 | 20.0 |
| Food | 2000 | 20.0 |