PIVOT and UNPIVOT
Turn rows into columns for a cross-tab report and back again: the PIVOT operator and its hidden grouping, conditional aggregation as the flexible alternative, building a dynamic PIVOT safely, and UNPIVOT with its NULL behaviour.
SELECT c.Name AS Category, LEFT(DATENAME(month, o.OrderDate), 3) AS Mon, SUM(oi.Quantity * oi.UnitPrice) AS ValueFROM dbo.Orders AS oJOIN dbo.OrderItems AS oi ON oi.OrderId = o.OrderIdJOIN dbo.Products AS p ON p.ProductId = oi.ProductIdJOIN dbo.Categories AS c ON c.CategoryId = p.CategoryIdWHERE o.Status <> 'Cancelled'GROUP BY c.Name, LEFT(DATENAME(month, o.OrderDate), 3), MONTH(o.OrderDate)ORDER BY c.Name, MONTH(o.OrderDate);Category Mon Value -------- --- ------ Building Feb 113.00 Building May 140.00 Fixings Mar 23.80 Fixings May 59.50 Fixings Jun 18.00 Paint Apr 120.50 Timber Mar 524.00 Timber May 84.00 Timber Jun 81.60 Tools Feb 38.00 Tools Mar 89.00 Tools May 18.50 Tools Jun 9.75
The PIVOT operator
PIVOT turns the values of one column (Mon) into column headings and puts an aggregate in each cell:
SELECT Category, [Feb], [Mar], [Apr], [May], [Jun]FROM ( SELECT c.Name AS Category, LEFT(DATENAME(month, o.OrderDate), 3) AS Mon, oi.Quantity * oi.UnitPrice AS Value FROM dbo.Orders AS o JOIN dbo.OrderItems AS oi ON oi.OrderId = o.OrderId JOIN dbo.Products AS p ON p.ProductId = oi.ProductId JOIN dbo.Categories AS c ON c.CategoryId = p.CategoryId WHERE o.Status <> 'Cancelled') AS srcPIVOT (SUM(Value) FOR Mon IN ([Feb], [Mar], [Apr], [May], [Jun])) AS pvORDER BY Category;Category Feb Mar Apr May Jun -------- ------ ------ ------ ------ ----- Building 113.00 NULL NULL 140.00 NULL Fixings NULL 23.80 NULL 59.50 18.00 Paint NULL NULL 120.50 NULL NULL Timber NULL 524.00 NULL 84.00 81.60 Tools 38.00 89.00 NULL 18.50 9.75
the source: a derived table (or CTE) with exactly the columns PIVOT needs; the aggregate: SUM(Value), which fills each cell;the spreading column: FOR Mon, whose values become headings;the column list: IN ([Feb], …), typed out in full. Values not in the list are ignored.
The hidden grouping trap
SELECT Category, [Feb], [Mar], [Apr], [May], [Jun]FROM ( SELECT c.Name AS Category, o.OrderId, -- one extra column LEFT(DATENAME(month, o.OrderDate), 3) AS Mon, oi.Quantity * oi.UnitPrice AS Value FROM dbo.Orders AS o JOIN dbo.OrderItems AS oi ON oi.OrderId = o.OrderId JOIN dbo.Products AS p ON p.ProductId = oi.ProductId JOIN dbo.Categories AS c ON c.CategoryId = p.CategoryId WHERE o.Status <> 'Cancelled') AS srcPIVOT (SUM(Value) FOR Mon IN ([Feb], [Mar], [Apr], [May], [Jun])) AS pvORDER BY Category;Category Feb Mar Apr May Jun -------- ------ ------ ------ ------ ----- Building 113.00 NULL NULL NULL NULL Building NULL NULL NULL 140.00 NULL Fixings NULL 23.80 NULL NULL NULL Fixings NULL NULL NULL 59.50 NULL Fixings NULL NULL NULL NULL 18.00 Paint NULL NULL 120.50 NULL NULL Timber NULL 524.00 NULL NULL NULL Timber NULL NULL NULL 84.00 NULL Timber NULL NULL NULL NULL 81.60 Tools 38.00 NULL NULL NULL NULL Tools NULL 89.00 NULL NULL NULL Tools NULL NULL NULL 18.50 NULL Tools NULL NULL NULL NULL 9.75
Conditional aggregation: the flexible alternative
CASE per column. It is longer, but the grouping is visible, each column can have its own aggregate or default, and totals are easy:
SELECT c.Name AS Category, SUM(CASE WHEN MONTH(o.OrderDate) = 2 THEN oi.Quantity * oi.UnitPrice ELSE 0 END) AS Feb, SUM(CASE WHEN MONTH(o.OrderDate) = 3 THEN oi.Quantity * oi.UnitPrice ELSE 0 END) AS Mar, SUM(CASE WHEN MONTH(o.OrderDate) = 4 THEN oi.Quantity * oi.UnitPrice ELSE 0 END) AS Apr, SUM(CASE WHEN MONTH(o.OrderDate) = 5 THEN oi.Quantity * oi.UnitPrice ELSE 0 END) AS May, SUM(CASE WHEN MONTH(o.OrderDate) = 6 THEN oi.Quantity * oi.UnitPrice ELSE 0 END) AS Jun, SUM(oi.Quantity * oi.UnitPrice) AS TotalFROM dbo.Orders AS oJOIN dbo.OrderItems AS oi ON oi.OrderId = o.OrderIdJOIN dbo.Products AS p ON p.ProductId = oi.ProductIdJOIN dbo.Categories AS c ON c.CategoryId = p.CategoryIdWHERE o.Status <> 'Cancelled'GROUP BY c.NameORDER BY c.Name;Category Feb Mar Apr May Jun Total -------- ------ ------ ------ ------ ----- ------ Building 113.00 0.00 0.00 140.00 0.00 253.00 Fixings 0.00 23.80 0.00 59.50 18.00 101.30 Paint 0.00 0.00 120.50 0.00 0.00 120.50 Timber 0.00 524.00 0.00 84.00 81.60 689.60 Tools 38.00 89.00 0.00 18.50 9.75 155.25
ELSE 0 turns the empty cells into 0. Leave it out and they are NULL, as with PIVOT. Counting works the same way: COUNT(CASE WHEN … THEN 1 END) counts the rows where the condition holds, because COUNT skips the NULLs the CASE returns otherwise.
A dynamic PIVOT
sp_executesql. QUOTENAME puts each value in square brackets so that a strange value cannot break, or inject into, the SQL:
DECLARE @cols nvarchar(max), @query nvarchar(max); SELECT @cols = STRING_AGG(QUOTENAME(Status), N', ') WITHIN GROUP (ORDER BY Status)FROM (SELECT DISTINCT Status FROM dbo.Orders) AS s; SET @query = N'SELECT CustomerId, ' + @cols + N'FROM (SELECT CustomerId, Status, OrderId FROM dbo.Orders) AS srcPIVOT (COUNT(OrderId) FOR Status IN (' + @cols + N')) AS pvORDER BY CustomerId;'; PRINT @cols;EXEC sp_executesql @query;[Cancelled], [Pending], [Shipped]
CustomerId Cancelled Pending Shipped
---------- --------- ------- -------
1 0 0 2
2 0 1 1
3 1 0 0
4 0 0 2
6 0 0 2
8 0 1 0UNPIVOT: columns back into rows
UNPIVOT does the reverse: it turns a set of columns into two columns, one holding the old column name and one the value. A stocktake sheet arrives wide, one column per product:
CREATE TABLE #Stocktake (Shop nvarchar(20), Hammers int, Drills int, Tapes int);INSERT INTO #Stocktake VALUES (N'Leeds', 12, 3, 20), (N'York', 8, NULL, 15); SELECT Shop, Product, QtyFROM #StocktakeUNPIVOT (Qty FOR Product IN (Hammers, Drills, Tapes)) AS uORDER BY Shop, Product;Shop Product Qty ----- ------- --- Leeds Drills 3 Leeds Hammers 12 Leeds Tapes 20 York Hammers 8 York Tapes 15
CROSS APPLY with a VALUES list instead, which keeps every cell. APPLY is the next lesson:
CREATE TABLE #Stocktake (Shop nvarchar(20), Hammers int, Drills int, Tapes int);INSERT INTO #Stocktake VALUES (N'Leeds', 12, 3, 20), (N'York', 8, NULL, 15); SELECT s.Shop, v.Product, v.QtyFROM #Stocktake AS sCROSS APPLY (VALUES (N'Hammers', s.Hammers), (N'Drills', s.Drills), (N'Tapes', s.Tapes)) AS v(Product, Qty)ORDER BY s.Shop, v.Product;Shop Product Qty ----- ------- ---- Leeds Drills 3 Leeds Hammers 12 Leeds Tapes 20 York Drills NULL York Hammers 8 York Tapes 15
Show a hint
Show the solution
SELECT Status, COUNT(CASE WHEN MONTH(OrderDate) BETWEEN 1 AND 3 THEN 1 END) AS Q1, COUNT(CASE WHEN MONTH(OrderDate) BETWEEN 4 AND 6 THEN 1 END) AS Q2, COUNT(*) AS TotalFROM dbo.OrdersGROUP BY StatusORDER BY Status;Warning: Null value is eliminated by an aggregate or other SET operation. Status Q1 Q2 Total --------- -- -- ----- Cancelled 0 1 1 Pending 0 2 2 Shipped 4 3 7
Remember
PIVOT turns the values of one column into columns, with an aggregate in each cell. The column list in IN (…) must be typed out; values not listed are ignored. PIVOT groups by every source column it is not told about: feed it from a narrow derived table. Empty cells are NULL with SUM (0 with COUNT). Conditional aggregation (SUM(CASE …) with GROUP BY) does the same, with visible grouping and easy totals. A dynamic PIVOT builds the column list with STRING_AGG(QUOTENAME(…)) and runs it with sp_executesql. UNPIVOT turns columns into rows and drops NULLs; CROSS APPLY (VALUES …) keeps them.