GROUP BY — Learn COUNT DISTINCT, FILTER, ROLLUP, two-stage aggregation, and share analysis in Practice
AdvancedGROUP BYCOUNT DISTINCTConditional aggregation / FILTERROLLUP / two-stage aggregationShare analysisPostgreSQL5 questions
QUESTION 6
Unique counts — Count distinct values within each group with COUNT(DISTINCT)
COUNT(DISTINCT)Unique countsDeduplicated aggregationUnique aggregation
Background

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)
DISTINCT means “distinct values”: 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.
Problem

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.

Source table
► page_views (7 rows)
view_idview_dateuser_id
12024-05-01U1
22024-05-01U1
32024-05-01U2
42024-05-02U2
52024-05-02U3
62024-05-02U3
72024-05-02U1

※ On 5/1, U1 views twice (3 total views, 2 actual people); on 5/2, U3 views twice (4 total views, 3 actual people).

Expected Output

Expected output (view_date ascending):

view_datetotal_viewsunique_users
2024-05-0132
2024-05-0243

total_views is the number of rows, while unique_users is the number of distinct user_id values. Revisits make total_views larger than unique_users.

QUESTION 7
Conditional aggregation — Pivot statuses into columns with FILTER
FILTER clauseConditional aggregationPivot / wide formatCross-tabulation
Background

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)
The decisive difference between WHERE and FILTER: WHERE filters rows out of the entire group, so the same condition affects every aggregate. 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.
Problem

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.

Source table
► orders (8 rows)
order_idcategorystatusamount
1Bookspaid1200
2Bookspaid800
3Bookspaid600
4Bookspending500
5Bookscancelled300
6Toyspaid2000
7Toyscancelled1500
8Toyspending700

※ Books = 5 orders (paid 3 / pending 1 / cancelled 1); Toys = 3 orders (paid 1 / pending 1 / cancelled 1). paid_amount sums only the amounts of paid rows.

Expected Output

Expected output (total_orders descending):

categorytotal_orderspaid_orderspending_orderscancelled_orderspaid_amount
Books53112600
Toys31112000

Books paid_amount = 1200 + 800 + 600 = 2600; Toys = 2000 (only order 6 is paid). The vertical statuses have been expanded into horizontal columns.

QUESTION 8
Subtotals and grand totals — Produce summary rows at once with ROLLUP
GROUP BY ROLLUPGROUPING()Subtotals / grand totalAggregation hierarchy
Background

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
A collapsed column becomes NULL: On subtotal and grand-total rows produced by ROLLUP, the value of the collapsed column is NULL. Because that is indistinguishable from an original NULL value, test GROUPING(column)=1 (the column was rolled up) and replace it with a label such as '(All)' using CASE.
Problem

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).

Source table
► sales (4 rows)
regionproductamount
EastApple100
EastBanana150
WestApple200
WestBanana250

※ 4 detail rows + East subtotal (250) + West subtotal (450) + grand total (700) = 7 rows.

Expected Output

Expected output (subtotals and grand total at the end):

regionproducttotal_amount
EastApple100
EastBanana150
East(All)250
WestApple200
WestBanana250
West(All)450
(All)(All)700

Rows labeled (All) are subtotals or the grand total. East = 100 + 150 = 250, West = 200 + 250 = 450, and grand total = 250 + 450 = 700.

QUESTION 9
Two-stage aggregation — Correctly calculate an average of customer totals with a derived table
Subquery / CTETwo-stage aggregationChange of grainAggregation of aggregation
Background

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;
Changing the grain changes the meaning: 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.
Problem

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.

Source table
► orders (6 rows)
order_idregioncustomer_idamount
1EastC11000
2EastC12000
3EastC23000
4WestC3500
5WestC3500
6WestC42000

※ East has 2 customers, C1 (3000) and C2 (3000); West has 2, C3 (1000) and C4 (2000). Average by customer count, not order count.

Expected Output

Expected output (avg_customer_spend descending):

regioncustomer_countavg_customer_spend
East23000
West21500

East = (3000 + 3000) / 2 = 3000; West = (1000 + 2000) / 2 = 1500. ※ The order average would be East 2000 and West 1000 — a different metric.

QUESTION 10
Share analysis — Calculate each group’s share of the whole with a window aggregate
Window functionsSUM() OVER ()Share / percentageAggregation over aggregation
Background

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 (%)
The double aggregation in SUM(SUM(x)) OVER (): The inner 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.
Problem

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.

Source table
► sales (5 rows)
sale_idcategoryamount
1Electronics4000
2Electronics2000
3Clothing1500
4Clothing500
5Food2000

※ Electronics = 6000, Clothing = 2000, Food = 2000; grand total = 10000. Each share is its category total ÷ 10000 × 100.

Expected Output

Expected output (category_total descending):

categorycategory_totalpct_of_total
Electronics600060.0
Clothing200020.0
Food200020.0

6000/10000 = 60.0%, and 2000/10000 = 20.0%. The shares sum to 100%.