Back to Notes

SQL Pivoting

SQL Pivoting

Transform row data into columns (pivot) or column data into rows (unpivot). SQL has no native PIVOT in standard SQL — use CASE WHEN.


Manual Pivot with CASE WHEN

Input: long format — one row per (user, metric)

user_id | metric       | value
--------|--------------|------
1       | clicks       | 10
1       | impressions  | 200
1       | conversions  | 3
2       | clicks       | 15
...

Output: wide format — one row per user, one column per metric

SELECT
  user_id,
  MAX(CASE WHEN metric = 'clicks'      THEN value END) AS clicks,
  MAX(CASE WHEN metric = 'impressions' THEN value END) AS impressions,
  MAX(CASE WHEN metric = 'conversions' THEN value END) AS conversions
FROM user_metrics
GROUP BY user_id;

Why MAX? After GROUP BY, each group has one row per user. CASE returns the value or NULL; MAX picks the non-NULL value (only one exists per group).


Pivot: Count / Sum Pattern

-- Orders by status per month
SELECT
  DATE_TRUNC('month', created_at)                                   AS month,
  COUNT(CASE WHEN status = 'delivered' THEN 1 END)                  AS delivered,
  COUNT(CASE WHEN status = 'cancelled' THEN 1 END)                  AS cancelled,
  COUNT(CASE WHEN status = 'pending'   THEN 1 END)                  AS pending,
  SUM(CASE WHEN status = 'delivered'   THEN amount ELSE 0 END)      AS delivered_revenue
FROM orders
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month;

Pivot: Transpose Rows to Columns (Classic Interview Problem)

Problem: Products table with (product, quarter, revenue). Show one row per product with Q1–Q4 as columns.

SELECT
  product,
  SUM(CASE WHEN quarter = 'Q1' THEN revenue ELSE 0 END) AS Q1,
  SUM(CASE WHEN quarter = 'Q2' THEN revenue ELSE 0 END) AS Q2,
  SUM(CASE WHEN quarter = 'Q3' THEN revenue ELSE 0 END) AS Q3,
  SUM(CASE WHEN quarter = 'Q4' THEN revenue ELSE 0 END) AS Q4
FROM product_revenue
GROUP BY product;

PostgreSQL CROSSTAB (tablefunc extension)

More concise for known pivot values:

-- Enable extension (once)
CREATE EXTENSION IF NOT EXISTS tablefunc;

SELECT * FROM crosstab(
  'SELECT user_id, metric, value FROM user_metrics ORDER BY 1, 2',
  'SELECT DISTINCT metric FROM user_metrics ORDER BY 1'
) AS ct(user_id INT, clicks INT, conversions INT, impressions INT);

Less portable than CASE WHEN — stick with CASE for interviews.


Unpivot — Wide to Long Format

Turn column values back into rows.

-- Given: one row per product with Q1, Q2, Q3, Q4 columns
-- Want: one row per (product, quarter, revenue)

-- PostgreSQL: UNNEST + ARRAY
SELECT product,
  quarter,
  revenue
FROM products
CROSS JOIN UNNEST(
  ARRAY['Q1', 'Q2', 'Q3', 'Q4'],
  ARRAY[q1,   q2,   q3,   q4]
) AS t(quarter, revenue);

-- Portable: UNION ALL
SELECT product, 'Q1' AS quarter, q1 AS revenue FROM products
UNION ALL
SELECT product, 'Q2',            q2             FROM products
UNION ALL
SELECT product, 'Q3',            q3             FROM products
UNION ALL
SELECT product, 'Q4',            q4             FROM products
ORDER BY product, quarter;

DataLemur / Interview Pattern

Problem type: "Show total X per category/status as separate columns"

Template:

SELECT
  group_col,
  SUM/COUNT(CASE WHEN category_col = 'val1' THEN metric_col [ELSE 0] END) AS val1,
  SUM/COUNT(CASE WHEN category_col = 'val2' THEN metric_col [ELSE 0] END) AS val2,
  ...
FROM table
GROUP BY group_col;

Use SUM with ELSE 0 when you need totals.
Use COUNT without ELSE when you need non-null counts (NULL values not counted).
Use MAX when exactly one value exists per group (e.g., pivoting attributes).


Related

  • [[Aggregates]] — GROUP BY, CASE WHEN in aggregates
  • [[Window Functions]] — alternative approach for some pivot-like problems
  • [[UNION & Set Operations]] — manual ROLLUP with UNION ALL