Pivot with conditional aggregation — Turn rows into columns with FILTER
Applying FILTER from the basic level lets you expand long-format data into a wide cross-tabulation (pivot). Place each status aggregate in its own column to create a summary table with one row per category.
-- Create a "column" for each status value (pivot) SUM(amount) FILTER (WHERE status = 'completed') -- completed column SUM(amount) FILTER (WHERE status = 'pending') -- pending column COALESCE(SUM(...) FILTER (...), 0) -- turn NULL for no match into 0
SUM(...) FILTER(...) returns NULL, not 0. In a cross-tabulation, you often want an empty cell to display as 0, so use COALESCE(aggregate, 0) to convert it. The CASE expression form, SUM(CASE WHEN status='completed' THEN amount ELSE 0 END), produces the same table.From orders, create a pivot table with categories as rows and statuses as columns. Output columns: category, completed_amt, pending_amt, cancelled_amt, total_amt. Use 0 for a cell with no matching rows, and sort by total_amt descending.
| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 500 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | pending | 1500 |
| 6 | Toys | cancelled | 1000 |
| 7 | Food | completed | 600 |
| 8 | Food | pending | 400 |
| category | completed_amt | pending_amt | cancelled_amt | total_amt |
|---|---|---|---|---|
| Toys | 3000 | 1500 | 1000 | 5500 |
| Books | 2000 | 0 | 500 | 2500 |
| Food | 600 | 400 | 0 | 1000 |
SELECT category, COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS completed_amt, -- completed total (use 0 when there is no match) COALESCE(SUM(amount) FILTER (WHERE status = 'pending'), 0) AS pending_amt, -- pending total (use 0 when there is no match) COALESCE(SUM(amount) FILTER (WHERE status = 'cancelled'), 0) AS cancelled_amt, -- cancelled total (use 0 when there is no match) SUM(amount) AS total_amt -- total across all statuses (no FILTER = the whole group) FROM orders GROUP BY category -- row axis: group by category ORDER BY total_amt DESC; -- sort by total, descending /* Execution order (logical evaluation order in SQL): 1. FROM orders → read the rows 2. GROUP BY category → group the rows 3. FILTER aggregates → evaluate the aggregate functions 4. SELECT → evaluate the columns (format with COALESCE) 5. ORDER BY total_amt DESC → sort and output */
LEGEND
① FROM orders (8 rows)
FROM ordersRead the 8 rows of the orders table. There are three status values — completed / pending / cancelled — which will be expanded into columns.| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | completed | 800 |
| 3 | Books | cancelled | 500 |
| 4 | Toys | completed | 3000 |
| 5 | Toys | pending | 1500 |
| 6 | Toys | cancelled | 1000 |
| 7 | Food | completed | 600 |
| 8 | Food | pending | 400 |
SUM(amount) FILTER (WHERE status='...') for each status value as a "column" to make a cross-tabulation with one row per category. You can generate several columns at once with one grouping and one scan, so there is no need to reread the detail rows repeatedly.SUM(...) FILTER(...) returns NULL, not 0. For display and downstream calculations, use COALESCE(aggregate, 0) to normalize it to 0. Remember the difference: COUNT returns 0, while SUM/AVG/MAX return NULL when there are no values.SUM(x) FILTER (WHERE c) produces the same result as SUM(CASE WHEN c THEN x END). FILTER is the readable first choice in PostgreSQL; use the CASE form when porting to other databases. Both are the heart of a pivot that separates columns by condition.GROUP BY category, status returns you to the original long format. In a pivot, GROUP BY only the row axis (category) and expand the column axis (status) in FILTER conditions.Group time series with DATE_TRUNC — Round dates to the month for monthly aggregation
When aggregating time-series data, truncate dates to units such as months or weeks before grouping. PostgreSQL's DATE_TRUNC('month', ts) rounds a timestamp to the first day of that month at 00:00, so rows from the same month naturally fall into one group.
-- Truncate to the month start and group by "month" DATE_TRUNC('month', order_date) -- 2024-02-15 → 2024-02-01 GROUP BY DATE_TRUNC('month', order_date)
::date makes the result easier to read.From orders, calculate the monthly order count and total amount. Output columns: month, order_count, total_amount. Represent month as the first day of the month (date type), and sort by month ascending.
| order_id | order_date | amount |
|---|---|---|
| 1 | 2024-01-05 | 1000 |
| 2 | 2024-01-20 | 1500 |
| 3 | 2024-02-03 | 2000 |
| 4 | 2024-02-15 | 500 |
| 5 | 2024-02-28 | 1000 |
| 6 | 2024-03-10 | 3000 |
| 7 | 2024-03-22 | 2000 |
| month | order_count | total_amount |
|---|---|---|
| 2024-01-01 | 2 | 2500 |
| 2024-02-01 | 3 | 3500 |
| 2024-03-01 | 2 | 5000 |
SELECT DATE_TRUNC('month', order_date)::date AS month, -- truncate to month start; ::date removes the time for display COUNT(*) AS order_count, -- order count within the month SUM(amount) AS total_amount -- total amount within the month FROM orders GROUP BY DATE_TRUNC('month', order_date) -- group at the truncated month granularity (write the expression as-is) ORDER BY month; -- ascending month starts = chronological order /* Execution order (logical evaluation order in SQL): 1. FROM orders → read the rows 2. DATE_TRUNC → format the values (convert to month starts) 3. GROUP BY (month) → group the rows 4. COUNT / SUM → evaluate the aggregate functions 5. ORDER BY month → sort and output */
LEGEND
① FROM orders (7 rows)
FROM ordersRead 7 rows. The order_date values vary by day, but the goal is to round them to monthly granularity before aggregating.| order_id | order_date | amount |
|---|---|---|
| 1 | 2024-01-05 | 1000 |
| 2 | 2024-01-20 | 1500 |
| 3 | 2024-02-03 | 2000 |
| 4 | 2024-02-15 | 500 |
| 5 | 2024-02-28 | 1000 |
| 6 | 2024-03-10 | 3000 |
| 7 | 2024-03-22 | 2000 |
DATE_TRUNC('month', ts) rounds dates to month starts and bundles rows from the same month into one group. Simply switch the granularity among 'day' / 'week' / 'month' / 'quarter' / 'year' to move between daily, monthly, and yearly reports from the same data.DATE_TRUNC(...), write the same expression in GROUP BY. PostgreSQL also permits an output alias or ordinal such as GROUP BY 1, but making the expression explicit is more portable and makes the intent clear.DATE_TRUNC returns a timestamp with a time component. Drop the time with ::date, or create a 'YYYY-MM' string with TO_CHAR(month,'YYYY-MM'). It is easier to work with the data when the aggregation key and display formatting are treated separately.GROUP BY order_date splits rows by day — or by second when the value is a timestamp — and can explode a supposedly monthly report into many rows. Always round to the intended reporting granularity with DATE_TRUNC before grouping.GROUP BY EXTRACT(MONTH FROM order_date) groups only by the month number, so January 2024 and January 2025 are mixed together. If the year matters, use DATE_TRUNC or a (year, month) key.generate_series('2024-01-01','2024-03-01','1 month'), LEFT JOIN the aggregate result, and fill gaps with COALESCE(..., 0). Be especially aware in time-series work that GROUP BY returns only rows that appear in the data.Subtotals and grand totals with ROLLUP — Return detail, category subtotals, and the overall total in one query
For reports with total rows, GROUP BY ROLLUP is the shortest route. In addition to detail (a,b), ROLLUP(a, b) outputs the subtotal (a) and the grand total () together in one query.
-- Generate detail + category subtotals + grand total at once GROUP BY ROLLUP (category, status) -- Aggregate levels generated: (cat,status) / (cat) / () GROUPING(status) -- 1 means a subtotal/grand-total row with status rolled up
GROUPING(column) (1 for a subtotal or total at that column) to distinguish it from a real NULL and to label or sort the rows.From orders, return the category × status details, category subtotals, and overall grand total in one query. Display subtotal rows with status "(Subtotal)"; display the grand-total row with category "[All Categories]" and status "(Grand Total)". Return the rows in report order (category → details → subtotal → grand total last).
| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | cancelled | 800 |
| 3 | Toys | completed | 3000 |
| 4 | Toys | completed | 1000 |
| 5 | Toys | cancelled | 500 |
| 6 | Food | completed | 600 |
| category | status | total_amount |
|---|---|---|
| Books | cancelled | 800 |
| Books | completed | 1200 |
| Books | (Subtotal) | 2000 |
| Food | completed | 600 |
| Food | (Subtotal) | 600 |
| Toys | cancelled | 500 |
| Toys | completed | 4000 |
| Toys | (Subtotal) | 4500 |
| [All Categories] | (Grand Total) | 7100 |
SELECT COALESCE(category, '[All Categories]') AS category, -- grand-total category is NULL → replace it with a label COALESCE( status, CASE WHEN GROUPING(category) = 0 THEN '(Subtotal)' -- category remains, only status is rolled up = subtotal ELSE '(Grand Total)' END -- both dimensions are rolled up = grand total ) AS status, SUM(amount) AS total_amount -- total for each level (detail/subtotal/grand total) FROM orders GROUP BY ROLLUP (category, status) -- generate the three levels (cat,status)/(cat)/() ORDER BY GROUPING(category), category, -- put grand total (GROUPING=1) last / categories ascending GROUPING(status), status; -- put subtotal (GROUPING=1) last within each category /* Execution order (logical evaluation order in SQL): 1. FROM orders → read the rows 2. GROUP BY ROLLUP(category, status) → group (detail, subtotals, grand total) 3. SUM(amount) → evaluate the aggregate function 4. SELECT → evaluate the columns (format with COALESCE + GROUPING) 5. ORDER BY GROUPING(category), category, GROUPING(status), status → sort and output */
LEGEND
① FROM orders (6 rows)
FROM ordersRead 6 rows. The goal is to create category × status combinations, their subtotals, and the grand total in one pass.| order_id | category | status | amount |
|---|---|---|---|
| 1 | Books | completed | 1200 |
| 2 | Books | cancelled | 800 |
| 3 | Toys | completed | 3000 |
| 4 | Toys | completed | 1000 |
| 5 | Toys | cancelled | 500 |
| 6 | Food | completed | 600 |
ROLLUP(a, b) rolls up dimensions one at a time from right to left, (a,b)→(a)→(), and outputs detail, subtotal, and grand-total rows together. There is no need to write several UNION ALL queries, and the table is scanned only once.COALESCE and the result is immediately readable as a report.GROUPING(column)=1 means that column was rolled up. ORDER BY GROUPING(category), category, GROUPING(status), status gives a stable detail → subtotal → grand-total report order and reliably distinguishes these rows from real NULL data.status=NULL in a subtotal row as "missing data", you may count the subtotal together with the details. Always use GROUPING() to determine whether NULL was rolled up or came from the data.GROUP BY cat,status ∪ GROUP BY cat ∪ the overall total — is redundant and scans the table three times. ROLLUP produces the same result in one scan.Share and cumulative share — Use GROUP BY and window functions for each category's share of the whole
To show what percentage of the whole each category represents, layer a window function on top of the grouped result. In SUM(SUM(amount)) OVER (), the inner SUM is the group total and the outer SUM is the grand total across all groups: this is two-level aggregation.
-- share = group total / grand total SUM(amount) * 100.0 / SUM(SUM(amount)) OVER () -- cumulative share (accumulated from largest total downward) SUM(SUM(amount)) OVER (ORDER BY SUM(amount) DESC)
SUM(amount) can be referenced inside a window and why the nested SUM(SUM(...)) works. OVER () creates one window over all rows; OVER (ORDER BY ...) creates a cumulative (running) total.From orders, calculate the category total, share of the whole (%), and cumulative share (%). Output columns: category, total_amount, pct_of_total, cumulative_pct. Sort by total descending (the ABC-analysis order), and round percentages to one decimal place.
| order_id | category | amount |
|---|---|---|
| 1 | Books | 1000 |
| 2 | Books | 1000 |
| 3 | Toys | 3000 |
| 4 | Toys | 2000 |
| 5 | Food | 2000 |
| 6 | Food | 1000 |
| category | total_amount | pct_of_total | cumulative_pct |
|---|---|---|---|
| Toys | 5000 | 50.0 | 50.0 |
| Food | 3000 | 30.0 | 80.0 |
| Books | 2000 | 20.0 | 100.0 |
SELECT category, SUM(amount) AS total_amount, -- category total (the inner aggregate) ROUND( SUM(amount) * 100.0 / SUM(SUM(amount)) OVER (), 1 -- own group total ÷ grand total (window = all categories) ) AS pct_of_total, -- share (%) ROUND( SUM(SUM(amount)) OVER (ORDER BY SUM(amount) DESC) * 100.0 -- cumulative, adding from the largest total downward / SUM(SUM(amount)) OVER (), 1 ) AS cumulative_pct -- cumulative share (ABC analysis) FROM orders GROUP BY category -- aggregate by category first ORDER BY total_amount DESC; -- total descending (same order as the cumulative buildup) /* Execution order (logical evaluation order in SQL): 1. FROM orders → read the rows 2. GROUP BY category → group the rows 3. SUM(amount) → evaluate the aggregate function 4. Window functions → evaluate windows (preserve the row count) 5. ORDER BY total_amount DESC → sort and output */
LEGEND
① FROM orders (6 rows)
FROM ordersRead 6 rows. First sum the amount by category, then calculate each category's share of the whole.| order_id | category | amount |
|---|---|---|
| 1 | Books | 1000 |
| 2 | Books | 1000 |
| 3 | Toys | 3000 |
| 4 | Toys | 2000 |
| 5 | Food | 2000 |
| 6 | Food | 1000 |
SUM(amount) can be referenced inside the window. Remember that OVER () creates one window over everything, while OVER (ORDER BY ...) creates a cumulative (running) total.cumulative_pct accumulated in descending total order makes it immediately clear how many top categories account for 80% of the whole. It is common in focused management of inventory, sales, and customers (the Pareto principle).SUM(amount) / SUM(SUM(amount)) OVER () divides integers as-is, every quotient below 1 becomes 0. Multiply by 100.0, or cast to a real numeric type with ::numeric before dividing.WHERE SUM(...) OVER () > ... is invalid. To filter by share, wrap the calculation in a subquery or CTE and filter with WHERE outside it.SUM(...) OVER () to get each value's share of the whole; change it to OVER (PARTITION BY month) to get the share within that month. The same two-level aggregation idea can produce category share within monthly results, regional share, and many other cuts. A scalar subquery such as (SELECT SUM(amount) FROM orders) is also possible, but the window version reads the table only once and is easier to read.STRING_AGG / ARRAY_AGG — Aggregate values within each group into one list
Use STRING_AGG (string concatenation) or ARRAY_AGG (array construction) when you want to combine values within a group into one list. A defining feature is that ORDER BY and DISTINCT can be written inside the aggregate function.
-- Concatenate group values with commas (deduplicated and sorted) STRING_AGG(DISTINCT product, ', ' ORDER BY product) -- Aggregate into an array ARRAY_AGG(DISTINCT product ORDER BY product)
STRING_AGG(expr, delimiter ORDER BY ...) determines the order of list elements; it is different from the ORDER BY at the end of the query. Adding DISTINCT combines duplicate elements into one (and the ORDER BY expression must match the DISTINCT expression).From sales, calculate the number of distinct products, a product-name list, and the total quantity per category. Remove duplicate names and sort the product list alphabetically. Output columns: category, product_count, products, total_qty. Return only categories with at least 2 distinct products, sorted by total_qty descending.
| id | category | product | qty |
|---|---|---|---|
| 1 | Books | SQL Guide | 3 |
| 2 | Books | Python | 1 |
| 3 | Books | SQL Guide | 2 |
| 4 | Toys | Blocks | 5 |
| 5 | Toys | Puzzle | 2 |
| 6 | Toys | Blocks | 1 |
| 7 | Food | Coffee | 4 |
| category | product_count | products | total_qty |
|---|---|---|---|
| Toys | 2 | Blocks, Puzzle | 8 |
| Books | 2 | Python, SQL Guide | 6 |
SELECT category, COUNT(DISTINCT product) AS product_count, -- number of distinct product types STRING_AGG(DISTINCT product, ', ' ORDER BY product) AS products, -- concatenate unique product names alphabetically SUM(qty) AS total_qty -- total quantity FROM sales GROUP BY category -- group by category HAVING COUNT(DISTINCT product) >= 2 -- keep only groups with at least 2 products (post-aggregation filter) ORDER BY total_qty DESC; -- sort by total quantity, descending /* Execution order (logical evaluation order in SQL): 1. FROM sales → read the rows 2. GROUP BY category → group the rows 3. Aggregates → evaluate the aggregate functions 4. HAVING → filter the groups 5. ORDER BY total_qty DESC → sort and output */
LEGEND
① FROM sales (7 rows)
FROM salesRead 7 rows. Products are duplicated (SQL Guide appears twice for Books and Blocks twice for Toys); we will collapse them into product types.| id | category | product | qty |
|---|---|---|---|
| 1 | Books | SQL Guide | 3 |
| 2 | Books | Python | 1 |
| 3 | Books | SQL Guide | 2 |
| 4 | Toys | Blocks | 5 |
| 5 | Toys | Puzzle | 2 |
| 6 | Toys | Blocks | 1 |
| 7 | Food | Coffee | 4 |
STRING_AGG(DISTINCT x, ',' ORDER BY x), ORDER BY controls the order within the list and DISTINCT removes duplicate elements. These roles differ from the final ORDER BY, which controls row order.HAVING COUNT(DISTINCT product) >= 2 belongs in HAVING because the condition is evaluated after aggregation. At the WHERE stage, COUNT(DISTINCT ...) has not been calculated yet and cannot be referenced. The basic-level distinction between WHERE and HAVING applies directly to list aggregation."SQL Guide, Python, SQL Guide", with duplicates concatenated as-is. Remove duplicate list elements with DISTINCT inside the aggregate function, not at the end of the query.ORDER BY changes only the order of rows; it does not change the order inside the list produced by STRING_AGG. Specify element order with ORDER BY inside the aggregate.STRING_AGG(... ORDER BY ...) with a row-limit mindset — concatenate only the top N items, or truncate with LEFT(string, n) || '...'. If every item is needed, return an ARRAY_AGG array and format it in the application while keeping output size in mind.