Turning rows into columns

Pivot with SUM and CASE to put categories side by side, and unpivot with UNION ALL to stack them back.

Advanced10 min readLesson 36 of 38
In this lesson 7 sections
  1. The long shape and the wide shape
  2. Pivoting with SUM and CASE
  3. Pivoting by year
  4. Unpivoting: columns back into rows
  5. Common mistakes
  6. Dialect notes
  7. Snowflake notes
Jump to practice

GROUP BY puts each category on its own row. Reports and spreadsheets often want categories as columns: one column per stage, per year or per region, so the eye can compare across. Turning rows into columns is called a pivot.

The long shape and the wide shape

The same numbers, two ways. Open deals per lead source and stage, long:

SELECT COALESCE(lead_source, 'Unknown') AS source, stage, COUNT(*) AS deals
FROM opportunities
WHERE closed_at IS NULL
GROUP BY source, stage;

That’s one row per source-and-stage pair: easy for a database or a chart tool, hard to read. The wide version has one row per source and one column per stage:

source prospecting qualification proposal negotiation
Inbound 1 0 0 1
Outbound 1 1 0 1
Partner 1 0 0 0
Referral 0 0 3 2
Unknown 3 3 0 2
Webinar 0 1 2 1

Pivoting with SUM and CASE

This is lesson 17’s conditional aggregation, with one CASE per output column:

SELECT COALESCE(lead_source, 'Unknown') AS source,
       SUM(CASE WHEN stage = 'Prospecting'   THEN 1 ELSE 0 END) AS prospecting,
       SUM(CASE WHEN stage = 'Qualification' THEN 1 ELSE 0 END) AS qualification,
       SUM(CASE WHEN stage = 'Proposal'      THEN 1 ELSE 0 END) AS proposal,
       SUM(CASE WHEN stage = 'Negotiation'   THEN 1 ELSE 0 END) AS negotiation
FROM opportunities
WHERE closed_at IS NULL
GROUP BY source
ORDER BY source;

Each row goes into one group. Each CASE checks whether that row belongs in its column: 1 if so, 0 if not. SUM adds them up. Swap THEN 1 for THEN amount to pivot dollars instead of counts.

The catch: you have to write a CASE for every column. SQL can’t create columns from whatever values happen to be in the data. If a new stage appears next month, it won’t get a column until someone adds one. That’s why pivots usually happen at the last step, often in the reporting tool itself.

Pivoting by year

Dates pivot the same way, with a date range in each CASE (lesson 21’s half-open range):

SELECT u.name,
       SUM(CASE WHEN o.closed_at >= '2025-01-01' AND o.closed_at < '2026-01-01'
                THEN o.amount ELSE 0 END) AS won_2025,
       SUM(CASE WHEN o.closed_at >= '2026-01-01' AND o.closed_at < '2027-01-01'
                THEN o.amount ELSE 0 END) AS won_2026
FROM users AS u
LEFT JOIN opportunities AS o
       ON o.owner_id = u.user_id
      AND o.is_won = 1
GROUP BY u.name
ORDER BY u.name;
name won_2025 won_2026
Ana Ruiz 85000 150000
Ben Carter 44000 270000
Chloe Park 86000 73000
Dev Patel 0 120000
Erin Walsh 0 0

Two details carry over from earlier lessons:

  • The “won” test is in ON, not WHERE. In WHERE, it would remove Erin, who has no deals at all (lesson 15’s trap).
  • Erin’s row shows 0, not NULL. She has one row from the LEFT JOIN with NULL deal columns, so each CASE falls to ELSE 0.

Unpivoting: columns back into rows

Sometimes data arrives wide, like a spreadsheet with a column per quarter, and you need it long for a chart or a join. Stack one query per column with UNION ALL (lesson 25):

WITH wide AS (
    SELECT 'Ana Ruiz' AS name, 85000 AS won_2025, 150000 AS won_2026
)
SELECT name, '2025' AS year, won_2025 AS won FROM wide
UNION ALL
SELECT name, '2026',         won_2026        FROM wide;

Each SELECT takes one column and labels where it came from.

Common mistakes

Mistake Fix
COUNT(CASE ... ELSE 0 END) Counts the 0s too. Use SUM (lesson 17).
The category filter in WHERE after a LEFT JOIN Move it to ON
No ELSE 0 Empty cells become NULL instead of 0
Expecting new categories to appear automatically Every column is written by hand. Re-check when the data changes.

Dialect notes

Database Notes
SQL Server Has PIVOT and UNPIVOT operators. You still list every column.
PostgreSQL crosstab() in the tablefunc extension, or SUM(...) FILTER (WHERE stage = 'Proposal') as a shorter CASE
MySQL, SQLite SUM(CASE ...) only

SUM(CASE ...) works on every database, which is why it’s the version to learn first.

Snowflake notes

Snowflake has PIVOT and UNPIVOT:

SELECT *
FROM (SELECT lead_source, stage FROM opportunities WHERE closed_at IS NULL)
PIVOT (COUNT(stage) FOR stage IN ('Prospecting', 'Qualification', 'Proposal', 'Negotiation'));

Snowflake’s PIVOT can also take IN (ANY ORDER BY stage), which builds a column for every value in the data automatically, so it doesn’t need updating when new values appear. COUNT_IF(stage = 'Proposal') is also a shorter way to write one pivoted count.

More in the field guide: PIVOT and Conditional aggregation.

Try it

Show each rep’s won revenue with 2025 and 2026 side by side, one column per year. Every rep appears, including anyone with no wins, who shows 0. Sort by rep name. Your result should match the table above.

Write it in the sandbox below, then press Check answer.

SQL sandbox

Run queries against the sample CRM tables (accounts, opportunities,users, contacts and opportunity_history), right in your browser. Nothing you run leaves your computer.

Back to the top of the lesson