Pivot and Unpivot Patterns with CTEs
Pivot and Unpivot Patterns with CTEs
Pivoting transforms rows into columns (wide format). Unpivoting does the reverse. PostgreSQL doesn't have a native PIVOT statement, but CTEs combined with conditional aggregation make both patterns clean and readable.
Understanding the Transformation
Long format (unpivoted):
| month | metric | value |
|---------|-----------|-------|
| 2024-01 | revenue | 5000 |
| 2024-01 | ec_orders | 42 |
| 2024-02 | revenue | 6200 |
| 2024-02 | ec_orders | 51 |
Wide format (pivoted):
| month | revenue | ec_orders |
|---------|---------|--------|
| 2024-01 | 5000 | 42 |
| 2024-02 | 6200 | 51 |
Pattern 1: Pivot with CASE + GROUP BY
The standard PostgreSQL pivot technique:
-- Pivot product sales by category into columns
WITH monthly_category_sales AS (
SELECT
DATE_TRUNC('month', o.created_at)::DATE AS month,
p.category,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM ec_orders o
JOIN ec_order_items oi ON oi.order_id = o.order_id
JOIN ec_products p ON p.product_id = oi.product_id
GROUP BY 1, 2
)
SELECT
month,
ROUND(SUM(CASE WHEN category = 'Electronics' THEN revenue END), 2) AS electronics,
ROUND(SUM(CASE WHEN category = 'Clothing' THEN revenue END), 2) AS clothing,
ROUND(SUM(CASE WHEN category = 'Books' THEN revenue END), 2) AS books,
ROUND(SUM(CASE WHEN category = 'Sports' THEN revenue END), 2) AS sports,
ROUND(SUM(revenue), 2) AS total
FROM monthly_category_sales
GROUP BY month
ORDER BY month;
Key insight: SUM(CASE WHEN ...) returns NULL for months with no matching rows (which you usually want), or you can wrap in COALESCE(..., 0) for zeros.
Pattern 2: Pivot with crosstab() (tablefunc extension)
For dynamic pivot (when you don't know column names in advance), PostgreSQL's crosstab() function is available via the tablefunc extension:
-- Enable the extension (once per database)
CREATE EXTENSION IF NOT EXISTS tablefunc;
-- Crosstab pivot
SELECT * FROM crosstab(
-- Query 1: source data (must be ordered by row, category)
$$
SELECT
DATE_TRUNC('month', o.created_at)::TEXT AS month,
p.category,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM ec_orders o
JOIN ec_order_items oi ON oi.order_id = o.order_id
JOIN ec_products p ON p.product_id = oi.product_id
GROUP BY 1, 2
ORDER BY 1, 2
$$,
-- Query 2: distinct category values
$$ SELECT DISTINCT category FROM ec_products ORDER BY category $$
) AS pivot_table(
month TEXT,
books NUMERIC,
clothing NUMERIC,
electronics NUMERIC,
home NUMERIC,
sports NUMERIC
);
Trade-off:
crosstab()requires knowing the output columns at query time (listed in the AS clause). For truly dynamic pivot, you need to build the SQL string dynamically in application code.
Pattern 3: Unpivot with UNION ALL
Turn wide format into long format:
-- Suppose we have a summary table with multiple metric columns
-- Unpivot it into long format for easier analysis
WITH wide_summary AS (
SELECT
DATE_TRUNC('month', o.created_at)::DATE AS month,
COUNT(DISTINCT o.customer_id) AS unique_customers,
COUNT(DISTINCT o.order_id) AS total_orders,
ROUND(SUM(oi.quantity * oi.unit_price), 2) AS total_revenue,
ROUND((AVG(oi.quantity * oi.unit_price))::NUMERIC, 2) AS avg_order_value
FROM ec_orders o
JOIN ec_order_items oi ON oi.order_id = o.order_id
GROUP BY 1
)
-- Unpivot: each metric becomes its own row
SELECT month, 'unique_customers' AS metric, unique_customers::NUMERIC AS value FROM wide_summary
UNION ALL
SELECT month, 'total_orders', total_orders::NUMERIC FROM wide_summary
UNION ALL
SELECT month, 'total_revenue', total_revenue FROM wide_summary
UNION ALL
SELECT month, 'avg_order_value', avg_order_value FROM wide_summary
ORDER BY month, metric;
Example 1: Quarterly Sales by Product Line
WITH quarterly_sales AS (
SELECT
p.category,
'Q' || EXTRACT(QUARTER FROM o.created_at)::TEXT AS quarter,
EXTRACT(YEAR FROM o.created_at)::TEXT AS year,
SUM(oi.quantity * oi.unit_price) AS revenue
FROM ec_orders o
JOIN ec_order_items oi ON oi.order_id = o.order_id
JOIN ec_products p ON p.product_id = oi.product_id
GROUP BY 1, 2, 3
)
SELECT
category,
ROUND(SUM(CASE WHEN year = '2023' AND quarter = 'Q1' THEN revenue END), 2) AS "2023-Q1",
ROUND(SUM(CASE WHEN year = '2023' AND quarter = 'Q2' THEN revenue END), 2) AS "2023-Q2",
ROUND(SUM(CASE WHEN year = '2023' AND quarter = 'Q3' THEN revenue END), 2) AS "2023-Q3",
ROUND(SUM(CASE WHEN year = '2023' AND quarter = 'Q4' THEN revenue END), 2) AS "2023-Q4",
ROUND(SUM(CASE WHEN year = '2024' AND quarter = 'Q1' THEN revenue END), 2) AS "2024-Q1",
ROUND(SUM(CASE WHEN year = '2024' AND quarter = 'Q2' THEN revenue END), 2) AS "2024-Q2"
FROM quarterly_sales
GROUP BY category
ORDER BY category;
Example 2: Stock Price Pivot — Month as Columns
Show average monthly closing price per company (months as columns):
WITH monthly_price AS (
SELECT
company_id,
EXTRACT(MONTH FROM price_date)::INT AS month_num,
AVG(close_price) AS avg_close
FROM stock_prices
GROUP BY company_id, month_num
)
SELECT
c.ticker,
c.name,
c.sector,
ROUND((AVG(CASE WHEN month_num = 1 THEN avg_close END))::NUMERIC, 2) AS jan,
ROUND((AVG(CASE WHEN month_num = 2 THEN avg_close END))::NUMERIC, 2) AS feb,
ROUND((AVG(CASE WHEN month_num = 3 THEN avg_close END))::NUMERIC, 2) AS mar,
ROUND((AVG(CASE WHEN month_num = 4 THEN avg_close END))::NUMERIC, 2) AS apr,
ROUND((AVG(CASE WHEN month_num = 5 THEN avg_close END))::NUMERIC, 2) AS may,
ROUND((AVG(CASE WHEN month_num = 6 THEN avg_close END))::NUMERIC, 2) AS jun,
ROUND((AVG(CASE WHEN month_num = 7 THEN avg_close END))::NUMERIC, 2) AS jul,
ROUND((AVG(CASE WHEN month_num = 8 THEN avg_close END))::NUMERIC, 2) AS aug,
ROUND((AVG(CASE WHEN month_num = 9 THEN avg_close END))::NUMERIC, 2) AS sep,
ROUND((AVG(CASE WHEN month_num = 10 THEN avg_close END))::NUMERIC, 2) AS oct,
ROUND((AVG(CASE WHEN month_num = 11 THEN avg_close END))::NUMERIC, 2) AS nov,
ROUND((AVG(CASE WHEN month_num = 12 THEN avg_close END))::NUMERIC, 2) AS dec
FROM monthly_price mp
JOIN companies c ON c.company_id = mp.company_id
GROUP BY c.company_id, c.ticker, c.name, c.sector
ORDER BY c.sector, c.ticker;
Example 3: Sport Stats Matrix
Player stats across multiple seasons, pivoted by season:
WITH season_stats AS (
SELECT
player_id,
season,
SUM(goals) AS goals,
SUM(assists) AS assists
FROM player_stats ps JOIN matches m ON m.match_id = ps.match_id GROUP BY player_id, season
)
SELECT
p.name,
p.position,
SUM(CASE WHEN season = '2021' THEN goals END) AS goals_2021,
SUM(CASE WHEN season = '2022' THEN goals END) AS goals_2022,
SUM(CASE WHEN season = '2023' THEN goals END) AS goals_2023,
SUM(CASE WHEN season = '2021' THEN assists END) AS assists_2021,
SUM(CASE WHEN season = '2022' THEN assists END) AS assists_2022,
SUM(CASE WHEN season = '2023' THEN assists END) AS assists_2023
FROM season_stats ss
JOIN players p ON p.player_id = ss.player_id
GROUP BY p.player_id, p.name, p.position
ORDER BY p.name;