PIVOT and UNPIVOT in SQL Server, including dynamic PIVOT
Turn rows into columns with PIVOT, the conditional aggregation alternative that's often clearer, a dynamic PIVOT for unknown columns, and UNPIVOT back again.
PIVOT turns the values of one column into column headings and aggregates the data under them. Typical use: monthly revenue as one row with a column per category, instead of one row per category and month.
The examples run against the SpDevDemo database, where every product has a Category: Hardware, Software, Services or Training.
The data before pivoting
SELECT YEAR(o.OrderDate) AS Year, p.Category, SUM(l.Quantity * l.UnitPrice) AS Revenue
FROM dbo.Orders AS o
JOIN dbo.OrderLines AS l ON l.OrderId = o.OrderId
JOIN dbo.Products AS p ON p.ProductId = l.ProductId
WHERE o.OrderDate < '2026-01-01'
GROUP BY YEAR(o.OrderDate), p.Category
ORDER BY Year, p.Category;
Twelve rows: three years times four categories. For a report, you want three rows with a column per category.
PIVOT
SELECT Year, Hardware, Software, Services, Training
FROM (
SELECT YEAR(o.OrderDate) AS Year, p.Category, l.Quantity * l.UnitPrice AS Amount
FROM dbo.Orders AS o
JOIN dbo.OrderLines AS l ON l.OrderId = o.OrderId
JOIN dbo.Products AS p ON p.ProductId = l.ProductId
WHERE o.OrderDate < '2026-01-01'
) AS src
PIVOT (
SUM(Amount) FOR Category IN (Hardware, Software, Services, Training)
) AS pvt
ORDER BY Year;
Year Hardware Software Services Training
---- ----------- ----------- ----------- -----------
2023 20686335.00 20467612.00 20790694.00 20167896.00
2024 20749412.00 20513356.00 20847869.00 20227290.00
2025 20688436.00 20467710.00 20781195.00 20168530.00
Warning: Null value is eliminated by an aggregate or other SET operation.
The warning is expected. Behind the scenes, each column is a SUM over a CASE that is NULL for the other categories, and SUM skips NULLs. It’s harmless here.
How to read it:
- The derived table
srcsupplies exactly the columnsPIVOTneeds: the row grouping (Year), the column to spread (Category) and the value to aggregate (Amount). FOR Category IN (...)lists the values that become columns. They’re identifiers, so values with spaces need brackets:[Field Services].- Every column of
srcthat isn’t spread or aggregated becomes a grouping column. That’s the main source of PIVOT bugs: an extra column in the source (anOrderId, say) silently turns one row per year into one row per order. Always pivot from a derived table that contains only the columns you need.
The same thing with conditional aggregation
PIVOT is syntax over a pattern you can write yourself:
SELECT YEAR(o.OrderDate) AS Year,
SUM(CASE WHEN p.Category = 'Hardware' THEN l.Quantity * l.UnitPrice END) AS Hardware,
SUM(CASE WHEN p.Category = 'Software' THEN l.Quantity * l.UnitPrice END) AS Software,
SUM(CASE WHEN p.Category = 'Services' THEN l.Quantity * l.UnitPrice END) AS Services,
SUM(CASE WHEN p.Category = 'Training' THEN l.Quantity * l.UnitPrice END) AS Training,
SUM(l.Quantity * l.UnitPrice) AS Total
FROM dbo.Orders AS o
JOIN dbo.OrderLines AS l ON l.OrderId = o.OrderId
JOIN dbo.Products AS p ON p.ProductId = l.ProductId
WHERE o.OrderDate < '2026-01-01'
GROUP BY YEAR(o.OrderDate)
ORDER BY Year;
It produces the same numbers, and it’s often the better choice:
- You can pivot several measures at once (revenue and order count per category), which
PIVOTcan’t. - You can add a total column, or use different aggregates per column.
- The grouping is explicit in
GROUP BY, so extra columns can’t sneak in.
The plans are typically the same, because PIVOT is processed as this kind of aggregation internally; that’s where the warning comes from.
Dynamic PIVOT, when you don’t know the columns
PIVOT needs the column list written in the query. When the values come from data (categories that change, or one column per month), build the query as a string:
DECLARE @columns NVARCHAR(MAX), @sql NVARCHAR(MAX);
SELECT @columns = STRING_AGG(QUOTENAME(Category), N', ') WITHIN GROUP (ORDER BY Category)
FROM (SELECT DISTINCT Category FROM dbo.Products) AS c;
SET @sql = N'
SELECT Year, ' + @columns + N'
FROM (
SELECT YEAR(o.OrderDate) AS Year, p.Category, l.Quantity * l.UnitPrice AS Amount
FROM dbo.Orders AS o
JOIN dbo.OrderLines AS l ON l.OrderId = o.OrderId
JOIN dbo.Products AS p ON p.ProductId = l.ProductId
WHERE o.OrderDate < @before
) AS src
PIVOT (SUM(Amount) FOR Category IN (' + @columns + N')) AS pvt
ORDER BY Year;';
EXEC sp_executesql @sql, N'@before date', @before = '2026-01-01';
Two details make this safe:
QUOTENAMEbrackets each value, so a category namedx]; DROP TABLE dbo.Orders; --becomes a harmless column name, not code.- Values that aren’t column names (here, the date) are passed as real parameters with
sp_executesql, not pasted into the string.
STRING_AGG needs SQL Server 2017 or later. On older versions, STUFF((SELECT ', ' + QUOTENAME(Category) ... FOR XML PATH('')), 1, 2, '') does the same.
The catch with dynamic pivots: the result’s columns change with the data, so code that reads them by name (a report, an ORM) can break when a new category appears. Pivoting in the reporting tool or the application is often more robust.
UNPIVOT: columns back to rows
WITH yearly AS (
SELECT Year, Hardware, Software, Services, Training
FROM (
SELECT YEAR(o.OrderDate) AS Year, p.Category, l.Quantity * l.UnitPrice AS Amount
FROM dbo.Orders AS o
JOIN dbo.OrderLines AS l ON l.OrderId = o.OrderId
JOIN dbo.Products AS p ON p.ProductId = l.ProductId
WHERE o.OrderDate < '2024-01-01'
) AS src
PIVOT (SUM(Amount) FOR Category IN (Hardware, Software, Services, Training)) AS pvt
)
SELECT Year, Category, Revenue
FROM yearly
UNPIVOT (Revenue FOR Category IN (Hardware, Software, Services, Training)) AS u;
UNPIVOT drops rows whose value is NULL, and all the listed columns must have the same type. CROSS APPLY (VALUES ...) has neither limitation; see CROSS APPLY.
Summary
- Fixed, known columns:
PIVOT, or conditional aggregation if you need several measures or a total. - Columns from data: dynamic SQL with
QUOTENAMEandsp_executesql, or pivot outside SQL. - Columns to rows:
CROSS APPLY (VALUES ...)for control,UNPIVOTfor brevity.