cswork / libraryOpen practice workspace

Articles

Pivot in SQL: Rows to Columns with Conditional Aggregation, FILTER, and PIVOT

Learn SQL pivot logic with conditional aggregation, zero-safe counts, PostgreSQL FILTER, dynamic JSON keys, and an unpivot example.

A pivot changes a tall result into a wide one. In SQL, the dependable pattern is conditional aggregation: choose the output row grain with GROUP BY, then write one aggregate for each fixed output column.

The column set must be known when the query is written. If the data decides how many columns exist, return a stable long or JSON shape and let the consumer expand it.

Pivot starts with the output grain and fixed columns

This report needs one row per department and one column for each of three known months. CASE routes revenue into the correct month; SUM collapses all source rows for the department. ELSE 0 states that a missing department-month cell should display zero.

Input: department_revenue

departmentmonth_namerevenue
AJan8000
BJan9000
CFeb10000
AFeb7000
AMar6000
SELECT
  department,
  SUM(CASE WHEN month_name = 'Jan' THEN revenue ELSE 0 END) AS jan_revenue,
  SUM(CASE WHEN month_name = 'Feb' THEN revenue ELSE 0 END) AS feb_revenue,
  SUM(CASE WHEN month_name = 'Mar' THEN revenue ELSE 0 END) AS mar_revenue
FROM department_revenue
GROUP BY department
ORDER BY department;

Output

departmentjan_revenuefeb_revenuemar_revenue
A800070006000
B900000
C0100000

Use ELSE 0 only when zero is the intended missing-cell policy. If a missing cell means “not observed,” omit the ELSE and let the aggregate return NULL. The CASE WHEN guide covers the expression rules behind the routing step.

Counts and zero-activity rows need a population table

To show employers with no submissions, start from employers and left join the activity table. Count a non-null submission key through each condition so the null-padded row contributes zero.

Input: employers

employer_idemployer_name
1Acme
2Borealis
3Cygnus

Input: submissions

submission_idemployer_idis_buggy
101true
111false
121false
132true
SELECT
  e.employer_id,
  e.employer_name,
  COUNT(s.submission_id) FILTER (WHERE s.is_buggy) AS buggy_count,
  COUNT(s.submission_id) FILTER (WHERE NOT s.is_buggy) AS clean_count
FROM employers AS e
LEFT JOIN submissions AS s
  ON s.employer_id = e.employer_id
GROUP BY e.employer_id, e.employer_name
ORDER BY e.employer_id;

Output

employer_idemployer_namebuggy_countclean_count
1Acme12
2Borealis10
3Cygnus00

The CASE version is COUNT(CASE WHEN s.is_buggy THEN s.submission_id END). Do not add ELSE 0 inside COUNT; zero is non-null and would make every joined row count. See SQL COUNT for the underlying NULL behavior.

FILTER makes PostgreSQL metric pivots easier to scan

Event types often arrive as rows even though a report needs separate metric columns. First pivot clicks and impressions by format, then compute the ratio. A format with clicks but no impressions gets a NULL rate because the denominator is zero.

Input: ad_events

event_idad_formatevent_typeevent_count
1videoimpression4
2videoclick1
3staticimpression6
4carouselclick2
WITH pivoted AS (
  SELECT
    ad_format,
    COALESCE(SUM(event_count) FILTER (WHERE event_type = 'click'), 0) AS clicks,
    COALESCE(SUM(event_count) FILTER (WHERE event_type = 'impression'), 0) AS impressions
  FROM ad_events
  GROUP BY ad_format
)
SELECT
  ad_format,
  clicks,
  impressions,
  ROUND(clicks::numeric / NULLIF(impressions, 0), 4) AS ctr
FROM pivoted
ORDER BY ad_format;

Output

ad_formatclicksimpressionsctr
carousel20NULL
static060.0000
video140.2500

FILTER is supported by PostgreSQL and keeps the aggregate visible. Other engines may prefer conditional CASE expressions, so confirm the target dialect instead of assuming one spelling is universal.

A pivot can become a group predicate

Sometimes the wide columns are useful only for testing a group. Here the question is which customers bought both Kindle and Alexa. One purchase row cannot equal both names, so the conditions must be evaluated across each customer's group.

Input: purchases

purchase_idcustomer_iditem_name
11Kindle
21Alexa
31Fire TV
42Kindle
53Alexa
64Fire TV
SELECT customer_id
FROM purchases
GROUP BY customer_id
HAVING BOOL_OR(item_name = 'Kindle')
   AND BOOL_OR(item_name = 'Alexa')
ORDER BY customer_id;

Output

customer_id
1

The CASE form uses MAX(CASE WHEN item_name = 'Kindle' THEN 1 ELSE 0 END) = 1. HAVING is the right stage because it filters groups after the conditional aggregate exists. The GROUP BY guide develops that distinction.

Dynamic columns and the reverse operation

A normal SQL result has a fixed set of columns. When month keys come from the data, a JSON object can keep the result schema stable while allowing each category to carry different keys.

Input: transactions

transaction_idcategoryamounttransaction_ts
1grocery20.002026-01-05 10:00
2grocery15.502026-02-11 09:30
3grocery4.502026-01-20 18:00
4pharmacy30.002026-02-02 12:00
WITH monthly AS (
  SELECT
    category,
    TO_CHAR(transaction_ts, 'YYYY-MM') AS month_key,
    SUM(amount) AS total_amount
  FROM transactions
  GROUP BY category, TO_CHAR(transaction_ts, 'YYYY-MM')
)
SELECT
  category,
  JSONB_OBJECT_AGG(month_key, total_amount ORDER BY month_key)::text AS by_month
FROM monthly
GROUP BY category
ORDER BY category;

Output

categoryby_month
grocery{"2026-01": 24.50, "2026-02": 15.50}
pharmacy{"2026-02": 30.00}

Another option is to return the long category, month_key, total_amount rows and pivot in the reporting layer. Generating SQL text is possible, but identifiers need allow-listing and the output schema still changes from run to run.

Unpivoting goes in the opposite direction. PostgreSQL can pair fixed program labels with their corresponding columns by unnesting two arrays. This keeps a NULL spend row so the consumer can distinguish “program exists with unknown spend” from “program row removed.”

Input: advertiser_spend

advertiser_idadvertiser_namespend_prog_aspend_prog_bspend_prog_c
1AlphaCo1000.00500.00NULL
2BetaInc200.001200.00300.00
3GammaLLC0.000.000.00
SELECT
  s.advertiser_id,
  s.advertiser_name,
  u.program,
  u.spend
FROM advertiser_spend AS s
CROSS JOIN LATERAL UNNEST(
  ARRAY['A', 'B', 'C'],
  ARRAY[s.spend_prog_a, s.spend_prog_b, s.spend_prog_c]
) AS u(program, spend)
ORDER BY s.advertiser_id, u.program;

Output

advertiser_idadvertiser_nameprogramspend
1AlphaCoA1000.00
1AlphaCoB500.00
1AlphaCoCNULL
2BetaIncA200.00
2BetaIncB1200.00
2BetaIncC300.00
3GammaLLCA0.00
3GammaLLCB0.00
3GammaLLCC0.00

Vendor PIVOT and UNPIVOT operators can shorten syntax when an engine is fixed, but they do not remove the need to define columns, aggregate duplicate cells, and choose a NULL policy. For broader analyst patterns around these reshapes, see SQL for data analysis.

FAQ

Which aggregate should I use in a pivot?

Match the measure. Use SUM for additive amounts, COUNT for occurrences, MAX or BOOL_OR for any-match flags, and an explicitly defined denominator for rates. Do not choose the aggregate only because it makes the sample output look right.

Why can a pivot not create arbitrary columns from the data?

A normal SQL statement exposes a fixed result schema when it is parsed. Return long rows or a map-like value when keys are data-dependent, or generate a carefully allow-listed statement outside the query.

Should missing pivot cells be zero or NULL?

That is a metric decision. Zero says the observed group had none of the measure. NULL says the value is missing, unknown, or not applicable. State the policy before adding ELSE 0 or COALESCE.

What is the difference between pivoting and conditional aggregation?

Conditional aggregation is the underlying row-to-column technique. Some engines also expose a PIVOT operator as dialect-specific syntax around the same fixed-column idea.

How do I debug a pivot?

Run the grouped source first at the intended row-and-column-key grain. Confirm one value exists per cell, then add one conditional column and compare its total with the long-form result before expanding the full column list.