Day 11: PIVOT & UNPIVOT
On this page
- A. Lesson overview
- B. Theory explained clearly
- C. Dataset and starting state
- D. Worked examples
- E. Guided practical lab — Category × quarter cross-tab, static then dynamic
- F. Independent practice
- G. Solutions and explanations
- H. Knowledge check
- I. Troubleshooting and cleanup
- J. Completion checklist, recap and references
By the end of this lesson you can
- Turn row values into columns with
PIVOT, naming the aggregate, the spreading column and the output columns. - Explain
PIVOT's implicit grouping — every source column not used in the pivot becomes a grouping column — and prevent it with a narrow derived table or CTE. - Choose between
PIVOTand conditional aggregation, including when you need several measures or a total. - Turn columns into rows with
UNPIVOT, explain why it drops NULLs, and useCROSS APPLY (VALUES …)when NULLs must be kept. - Build a column list from data with
STRING_AGGandQUOTENAME, and execute a dynamic pivot withsp_executesql. - Explain why identifiers in dynamic SQL must be quoted with
QUOTENAMEand values must be passed as parameters.
A. Lesson overview
| Week | 2 — Joins, set operations and advanced retrieval |
| Level | Intermediate → Advanced |
| Estimated time | 2 h 45 min essential + 30 min optional stretch |
| Prerequisites | Days 1–10, especially conditional aggregation (Day 2), CTEs (Day 4) and CROSS APPLY (VALUES …) (Day 10) |
| Syllabus topic | PIVOT & UNPIVOT — dynamic pivoting, unpivoting data |
| Foundations added today | Primer: building and running dynamic SQL safely with QUOTENAME, STRING_AGG and sp_executesql (full treatment on Day 28) |
Activity breakdown
| Activity | Time |
|---|---|
Part 1 — Rows to columns: PIVOT and its hidden grouping |
25 min |
Part 2 — Columns to rows: UNPIVOT and the APPLY alternative |
15 min |
| Part 3 — Primer: dynamic SQL for dynamic pivots | 25 min |
| Worked examples | 25 min |
| Guided lab | 30 min |
| Independent practice | 20 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. The board pack needs cross-tab tables: sales by category and quarter, payments by method and year, stock by product and warehouse. Analysts are tired of rewriting the column list every time a new year or warehouse appears, so they also want a version that discovers its columns from the data. Meanwhile, the data team receives a monthly spreadsheet in “wide” format and needs it in “long” format for loading.
B. Theory explained clearly
Part 1 — PIVOT
B1. What a pivot does
A pivot rotates distinct values of one column (the spreading column) into separate output columns, aggregating a value column for each. Data held “long”:
| Channel | MonthNo | NetSales |
|---|---|---|
| Online | 1 | 1321.89 |
| Store | 1 | 2427.68 |
| Online | 2 | 1428.80 |
| … | … | … |
becomes “wide”:
| MonthNo | Online | Store |
|---|---|---|
| 1 | 1321.89 | 2427.68 |
| 2 | 1428.80 | 2462.80 |
B2. PIVOT syntax
SELECT <grouping columns>, [Value1], [Value2], …
FROM (SELECT <grouping columns>, <spreading column>, <value column>
FROM …) AS src
PIVOT
(
<aggregate>(<value column>)
FOR <spreading column> IN ([Value1], [Value2], …)
) AS pvt;
| Part | Meaning |
|---|---|
Source (src) |
A derived table or CTE with only the columns the pivot needs |
| Aggregate | Exactly one aggregate: SUM, COUNT, MAX, … COUNT(*) is not allowed — count a column |
FOR … IN (…) |
The spreading column and the literal list of values that become columns. They must be written as identifiers, in square brackets when needed: [Online], [2025], [Bank Transfer] |
| Values not in the list | Silently ignored |
| Listed values with no data | NULL in that cell |
B3. The implicit grouping trap
PIVOT has no GROUP BY. It groups by every column of the source that is neither the spreading column nor the aggregated column. If the source carries an extra column — say OrderID — the result has one row per order instead of one per month. This is the single most common PIVOT bug. Always feed PIVOT from a narrow derived table or CTE that contains exactly: grouping columns + spreading column + value column.
B4. PIVOT or conditional aggregation?
-- PIVOT
SELECT MonthNo, [Online], [Store]
FROM (SELECT MONTH(o.OrderDate) AS MonthNo, o.SalesChannel, l.Quantity * l.UnitPrice * (1 - l.DiscountPct) AS LineNet
FROM …) AS src
PIVOT (SUM(LineNet) FOR SalesChannel IN ([Online], [Store])) AS p;
-- Conditional aggregation (Day 2)
SELECT MONTH(o.OrderDate) AS MonthNo,
SUM(CASE WHEN o.SalesChannel = 'Online' THEN … END) AS Online,
SUM(CASE WHEN o.SalesChannel = 'Store' THEN … END) AS Store
FROM … GROUP BY MONTH(o.OrderDate);
| Need | PIVOT |
Conditional aggregation |
|---|---|---|
| One measure, simple cross-tab | Compact | Fine |
| Several measures (orders and sales) per column | Needs several pivots or pre-combined values | Easy — add more CASE expressions |
| A row total column | Add the columns after pivoting | Add SUM(…) directly |
| Explicit grouping | Implicit (risky) | Explicit GROUP BY |
| Show 0 instead of NULL | COALESCE each output column |
ELSE 0 |
Both produce the same execution work in most cases; choose the one your team reads most easily. Dynamic SQL tends to use PIVOT because the generated text is shorter.
Part 2 — UNPIVOT
UNPIVOT rotates columns into rows:
SELECT <other columns>, <name column>, <value column>
FROM <source>
UNPIVOT
(
<value column> FOR <name column> IN ([Col1], [Col2], …)
) AS u;
Rules and traps:
- All unpivoted columns must have the same data type (including length) —
CASTthem in a derived table first. UNPIVOTdrops rows whose value is NULL. UnpivotingEmailandPhonefor 40 customers gives 66 rows, not 80, because 4 emails and 10 phones are NULL.UNPIVOTis not an exact inverse ofPIVOT: aggregated detail cannot be recovered, and NULL cells disappear.
CROSS APPLY (VALUES (…), (…)) from Day 10 is the flexible alternative: it keeps NULLs (filter them if you want), lets you unpivot several column pairs at once, and allows expressions.
Part 3 — Primer: dynamic SQL for dynamic pivots
PIVOT needs its column list written into the query. If the values come from data (new years, new warehouses), you must build the query text and execute it. That is dynamic SQL.
DECLARE @cols nvarchar(max),
@sql nvarchar(max);
-- 1. Build the column list from data, safely quoted
SELECT @cols = STRING_AGG(CAST(QUOTENAME(PaymentMethod) AS nvarchar(max)), N', ')
WITHIN GROUP (ORDER BY PaymentMethod)
FROM (SELECT DISTINCT PaymentMethod FROM Sales.Payment) AS m;
-- @cols = [Bank Transfer], [Card], [Gift Card], [PayPal]
-- 2. Build the statement
SET @sql = N'
SELECT PayYear, ' + @cols + N'
FROM (SELECT YEAR(PaymentDate) AS PayYear, PaymentMethod, Amount
FROM Sales.Payment) AS src
PIVOT (SUM(Amount) FOR PaymentMethod IN (' + @cols + N')) AS p
ORDER BY PayYear;';
PRINT @sql; -- 3. Always inspect what you built
EXEC sys.sp_executesql @sql; -- 4. Execute it
| Piece | Why |
|---|---|
QUOTENAME(x) |
Wraps an identifier in square brackets and escapes any ] inside it. A value such as Bank Transfer (with a space) — or a malicious value such as x]; DROP TABLE … — becomes one safe identifier. It accepts at most 128 characters and returns NULL for longer input |
STRING_AGG(… , N', ') WITHIN GROUP (ORDER BY …) |
Concatenates the quoted names in a defined order (SQL Server 2017+). Casting the input to nvarchar(max) avoids an error if the list exceeds 8,000 bytes |
nvarchar(max) variables and N'…' literals |
Avoid truncation and keep Unicode intact |
PRINT @sql |
Lets you read the generated SQL; copy it into a new window to debug |
sys.sp_executesql |
Runs the string. It also accepts parameters for values, which Day 28 covers in depth |
Security rule for this course: only identifiers (column names) are concatenated, and only after QUOTENAME. Values — dates, IDs, text typed by a user — are never concatenated; they are passed as sp_executesql parameters. Dynamic SQL also runs in its own scope: variables declared outside it are not visible inside unless passed as parameters.
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor), core data only |
| Database | RetailLab |
| Tables used | Sales.SalesOrder, Sales.SalesOrderLine, Sales.Payment, Sales.Customer, Inventory.Product, Inventory.Category, Inventory.Stock, Inventory.Warehouse |
| Permissions | SELECT on these tables; executing sp_executesql (available to every user; the statement inside runs with your own permissions) |
| Objects created today | None — Day 11 is read-only |
Preparation. None if RetailLab is unchanged. Otherwise run scripts/setup/04_reset_training_data.sql in SQLCMD Mode (destructive) or scripts 00 → 03.
Validation. Expected: 4, 2, 120861.55.
USE RetailLab;
GO
SELECT (SELECT COUNT(DISTINCT PaymentMethod) FROM Sales.Payment) AS PaymentMethods,
(SELECT COUNT(*) FROM Inventory.Warehouse) AS Warehouses,
(SELECT SUM(Amount) FROM Sales.Payment) AS TotalPayments;
D. Worked examples
Worked example 1 — 2025 sales by month and channel
SELECT MonthNo,
CAST([Online] AS decimal(12,2)) AS Online,
CAST([Store] AS decimal(12,2)) AS Store
FROM (SELECT MONTH(o.OrderDate) AS MonthNo,
o.SalesChannel,
l.Quantity * l.UnitPrice * (1 - l.DiscountPct) AS LineNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01') AS src
PIVOT (SUM(LineNet) FOR SalesChannel IN ([Online], [Store])) AS p
ORDER BY MonthNo;
Expected result: 12 rows matching Day 2’s lab — January 1321.89 / 2427.68 … December 6097.85 / 3620.80. The source has exactly three columns: MonthNo (grouping), SalesChannel (spreading) and LineNet (value).
Worked example 2 — The implicit grouping trap
SELECT COUNT(*) AS RowsReturned
FROM (SELECT o.OrderID, -- extra column!
MONTH(o.OrderDate) AS MonthNo,
o.SalesChannel,
l.Quantity * l.UnitPrice * (1 - l.DiscountPct) AS LineNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01') AS src
PIVOT (SUM(LineNet) FOR SalesChannel IN ([Online], [Store])) AS p;
Expected result: 198 — one row per order, because OrderID silently became a grouping column. Each row has a value in either Online or Store and NULL in the other. No error warns you.
Worked example 3 — Category by year, with a total column
WITH CategoryTree AS
(
SELECT CategoryID, CategoryName AS TopCategory FROM Inventory.Category WHERE ParentCategoryID IS NULL
UNION ALL
SELECT c.CategoryID, t.TopCategory FROM Inventory.Category AS c JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID
),
src AS
(
SELECT t.TopCategory,
YEAR(o.OrderDate) AS OrderYear,
l.Quantity * l.UnitPrice * (1 - l.DiscountPct) AS LineNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
JOIN CategoryTree AS t ON t.CategoryID = p.CategoryID
WHERE o.OrderStatus <> 'Cancelled'
)
SELECT TopCategory,
CAST([2024] AS decimal(12,2)) AS [2024],
CAST([2025] AS decimal(12,2)) AS [2025],
CAST([2024] + [2025] AS decimal(12,2)) AS Total
FROM src
PIVOT (SUM(LineNet) FOR OrderYear IN ([2024], [2025])) AS p
ORDER BY TopCategory;
Expected result
| TopCategory | 2024 | 2025 | Total |
|---|---|---|---|
| Accessories | 10440.94 | 11731.09 | 22172.03 |
| Camping | 27122.20 | 27572.05 | 54694.25 |
| Clothing | 21049.80 | 24438.80 | 45488.60 |
Numbers become column names by writing them in brackets: [2024]. If any cell were NULL, [2024] + [2025] would be NULL; use COALESCE([2024], 0) + COALESCE([2025], 0) in general.
Worked example 4 — Stock by warehouse (NULL means “not stocked”)
SELECT ProductCode,
[1] AS Midlands,
[2] AS Scotland
FROM (SELECT p.ProductCode, s.WarehouseID, s.QuantityOnHand
FROM Inventory.Stock AS s
JOIN Inventory.Product AS p ON p.ProductID = s.ProductID) AS src
PIVOT (SUM(QuantityOnHand) FOR WarehouseID IN ([1], [2])) AS p
ORDER BY ProductCode;
Expected result: 29 rows (one per product with any stock record). BPK-001: 18 and 40; BPK-002: 37 and NULL. Here NULL means “no stock record in that warehouse”, which is different from 0 (out of stock) — keep NULL, or label it, but do not blindly turn it into 0.
Worked example 5 — UNPIVOT drops NULLs; APPLY keeps them
-- UNPIVOT
SELECT COUNT(*) AS UnpivotRows
FROM (SELECT CustomerID, Email, CAST(Phone AS nvarchar(255)) AS Phone
FROM Sales.Customer) AS src
UNPIVOT (ContactValue FOR ContactType IN (Email, Phone)) AS u;
-- CROSS APPLY (VALUES …)
SELECT COUNT(*) AS ApplyRows,
COUNT(v.ContactValue) AS ApplyNonNullRows
FROM Sales.Customer AS c
CROSS APPLY (VALUES (N'Email', c.Email),
(N'Phone', CAST(c.Phone AS nvarchar(255)))) AS v (ContactType, ContactValue);
Expected result: 66; then 80 and 66. Without the CAST, the first query fails: The type of column “Phone” conflicts with the type of other columns specified in the UNPIVOT list.
Worked example 6 — A dynamic pivot of payments by method
DECLARE @cols nvarchar(max),
@sql nvarchar(max);
SELECT @cols = STRING_AGG(CAST(QUOTENAME(PaymentMethod) AS nvarchar(max)), N', ')
WITHIN GROUP (ORDER BY PaymentMethod)
FROM (SELECT DISTINCT PaymentMethod FROM Sales.Payment) AS m;
SET @sql = N'
SELECT PayYear, ' + @cols + N'
FROM (SELECT YEAR(PaymentDate) AS PayYear, PaymentMethod, Amount
FROM Sales.Payment) AS src
PIVOT (SUM(Amount) FOR PaymentMethod IN (' + @cols + N')) AS p
ORDER BY PayYear;';
PRINT @sql;
EXEC sys.sp_executesql @sql;
Expected result: the Messages tab shows the generated statement with [Bank Transfer], [Card], [Gift Card], [PayPal]. The result:
| PayYear | Bank Transfer | Card | Gift Card | PayPal |
|---|---|---|---|---|
| 2024 | 8372.08 | 32883.15 | 875.66 | 16480.42 |
| 2025 | 13738.20 | 34142.58 | 2165.58 | 12203.88 |
If a new payment method appeared in the data, it would become a new column automatically.
E. Guided practical lab — Category × quarter cross-tab, static then dynamic
Finance wants 2025 net sales with top-level categories as rows, quarters as columns, a row total and a grand-total row.
Step 1 — Build and check the narrow source
- Do: Create a CTE with exactly three columns.
- Run:
USE RetailLab; GO WITH CategoryTree AS ( SELECT CategoryID, CategoryName AS TopCategory FROM Inventory.Category WHERE ParentCategoryID IS NULL UNION ALL SELECT c.CategoryID, t.TopCategory FROM Inventory.Category AS c JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID ), src AS ( SELECT t.TopCategory, CONCAT('Q', DATEPART(QUARTER, o.OrderDate)) AS Qtr, l.Quantity * l.UnitPrice * (1 - l.DiscountPct) AS LineNet FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID JOIN Inventory.Product AS p ON p.ProductID = l.ProductID JOIN CategoryTree AS t ON t.CategoryID = p.CategoryID WHERE o.OrderStatus <> 'Cancelled' AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' ) SELECT COUNT(*) AS SourceRows, CAST(SUM(LineNet) AS decimal(12,2)) AS NetSales FROM src; - Observe:
360,63741.94. - Why it matters: The control total is fixed before pivoting.
- Confirm: matches Day 2.
Step 2 — Static PIVOT
-
Do: Replace the final
SELECT. -
Run:
SELECT TopCategory, CAST([Q1] AS decimal(12,2)) AS Q1, CAST([Q2] AS decimal(12,2)) AS Q2, CAST([Q3] AS decimal(12,2)) AS Q3, CAST([Q4] AS decimal(12,2)) AS Q4 FROM src PIVOT (SUM(LineNet) FOR Qtr IN ([Q1], [Q2], [Q3], [Q4])) AS p ORDER BY TopCategory; -
Observe:
TopCategory Q1 Q2 Q3 Q4 Accessories 2577.33 2355.86 2400.62 4397.27 Camping 6033.75 7806.15 4897.55 8834.60 Clothing 5917.38 7895.79 5412.80 5212.83 -
Why it matters: The spreading values are text (
'Q1'…), so they become column names directly. -
Confirm: 3 rows.
Step 3 — Add a row total and a grand-total row
- Do: Add a
Totalcolumn withCOALESCE, and a total row by feeding the pivot a source that already contains an “All categories” group. - Run: replace the final
SELECTwith:
(Start with a comma straight after the closing parenthesis of, src2 AS ( SELECT TopCategory, Qtr, LineNet FROM src UNION ALL SELECT N'All categories', Qtr, LineNet FROM src ) SELECT TopCategory, CAST([Q1] AS decimal(12,2)) AS Q1, CAST([Q2] AS decimal(12,2)) AS Q2, CAST([Q3] AS decimal(12,2)) AS Q3, CAST([Q4] AS decimal(12,2)) AS Q4, CAST(COALESCE([Q1],0) + COALESCE([Q2],0) + COALESCE([Q3],0) + COALESCE([Q4],0) AS decimal(12,2)) AS Total FROM src2 PIVOT (SUM(LineNet) FOR Qtr IN ([Q1], [Q2], [Q3], [Q4])) AS p ORDER BY CASE WHEN TopCategory = N'All categories' THEN 1 ELSE 0 END, TopCategory;src, and delete the previous finalSELECT.) - Observe: 4 rows. The last is All categories: 14528.46, 18057.81, 12710.98, 18444.70, 63741.94. The category totals are 11731.09, 27572.05 and 24438.80.
- Why it matters: Duplicating the source rows under a constant label is a simple way to get a total row from
PIVOT. (Alternatively, use conditional aggregation withGROUP BY ROLLUP.) - Confirm: the grand total equals the control total.
Step 4 — The dynamic version
- Do: Rebuild the query as dynamic SQL so the quarter columns come from the data. First build the column list (and the total expression) from the quarters that actually have orders, then embed them in the statement.
- Run:
DECLARE @cols nvarchar(max), @total nvarchar(max), @sql nvarchar(max); SELECT @cols = STRING_AGG(CAST(QUOTENAME(Qtr) AS nvarchar(max)), N', ') WITHIN GROUP (ORDER BY Qtr), @total = STRING_AGG(CAST(N'COALESCE(' + QUOTENAME(Qtr) + N', 0)' AS nvarchar(max)), N' + ') WITHIN GROUP (ORDER BY Qtr) FROM (SELECT DISTINCT CONCAT('Q', DATEPART(QUARTER, OrderDate)) AS Qtr FROM Sales.SalesOrder WHERE OrderStatus <> 'Cancelled' AND OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01') AS q; SET @sql = N' WITH CategoryTree AS ( SELECT CategoryID, CategoryName AS TopCategory FROM Inventory.Category WHERE ParentCategoryID IS NULL UNION ALL SELECT c.CategoryID, t.TopCategory FROM Inventory.Category AS c JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID ), src AS ( SELECT t.TopCategory, CONCAT(''Q'', DATEPART(QUARTER, o.OrderDate)) AS Qtr, l.Quantity * l.UnitPrice * (1 - l.DiscountPct) AS LineNet FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID JOIN Inventory.Product AS p ON p.ProductID = l.ProductID JOIN CategoryTree AS t ON t.CategoryID = p.CategoryID WHERE o.OrderStatus <> ''Cancelled'' AND o.OrderDate >= @FromDate AND o.OrderDate < @ToDate ), src2 AS ( SELECT TopCategory, Qtr, LineNet FROM src UNION ALL SELECT N''All categories'', Qtr, LineNet FROM src ) SELECT TopCategory, ' + @cols + N', ' + @total + N' AS Total FROM src2 PIVOT (SUM(LineNet) FOR Qtr IN (' + @cols + N')) AS p ORDER BY CASE WHEN TopCategory = N''All categories'' THEN 1 ELSE 0 END, TopCategory;'; PRINT @sql; EXEC sys.sp_executesql @sql, N'@FromDate date, @ToDate date', @FromDate = '2025-01-01', @ToDate = '2026-01-01'; - Observe: the same 4 rows as step 3 (values unrounded, so they show four decimal places).
- Why it matters: Three techniques are on display: quoted identifiers built from data (
@cols), a generated expression (@total), and values passed as parameters (@FromDate,@ToDate) rather than glued into the text. Single quotes inside the dynamic text are doubled (''Q''). - Confirm: the All categories total is 63741.94 (shown as 63741.9400). Change the parameter dates to 2024 and run again — the same code produces the 2024 table.
F. Independent practice
Task 1 — Orders per year by channel (static PIVOT)
Return one row per year with the number of orders (all statuses) in Online and Store columns, plus Total.
Success criteria: 2024: 104, 70, 174. 2025: 114, 88, 202.
Hint
COUNT(*) is not allowed in PIVOT; count a column that is never NULL, such as OrderID.
Task 2 — Stock by warehouse name
Pivot Inventory.Stock so each product is a row and the columns are the warehouse names — [Midlands Distribution Centre] and [Scotland Hub]. Show — where the product has no stock record in that warehouse.
Success criteria: 29 rows. BPK-002 shows 37 and —; BPK-003 shows 7 and 1.
Hint
The pivoted columns are int; to show — you must convert them to text in the outer SELECT, e.g. COALESCE(CAST([Scotland Hub] AS varchar(10)), '—').
Task 3 — Unpivot a wide table
Finance sends 2025 quarterly figures in wide format. Simulate it with the static pivot from lab step 2 in a CTE, then UNPIVOT it back to TopCategory, Qtr, NetSales rows.
Success criteria: 12 rows (3 categories × 4 quarters); the NetSales values add up to 63741.94.
Task 4 — Dynamic pivot of stock by warehouse (challenging)
Write dynamic SQL that pivots stock by warehouse name without hard-coding the names. Use QUOTENAME, STRING_AGG … WITHIN GROUP (ORDER BY WarehouseName) and sp_executesql. Print the generated SQL.
Success criteria: the generated IN list is [Midlands Distribution Centre], [Scotland Hub]; the result matches Task 2 (with NULLs instead of —). Inserting a third warehouse (in a test, inside a rolled-back transaction) would add a column without code changes.
Stretch challenge — Years discovered from the data, with growth
Write a dynamic pivot of top-level category × order year (non-cancelled), with years discovered from the data, followed by a Growth% column comparing the last two years present. Build the growth expression dynamically too.
Success criteria: columns [2024], [2025], GrowthPct. Accessories 10440.94 → 11731.09 (+12.4%), Camping 27122.20 → 27572.05 (+1.7%), Clothing 21049.80 → 24438.80 (+16.1%).
Hint
Find the two latest years with TOP (2) … ORDER BY OrderYear DESC into variables @prev and @last; quote them with QUOTENAME(CAST(@last AS nvarchar(4))).
G. Solutions and explanations
Show solution 1
SELECT OrderYear,
[Online],
[Store],
[Online] + [Store] AS Total
FROM (SELECT YEAR(OrderDate) AS OrderYear, SalesChannel, OrderID
FROM Sales.SalesOrder) AS src
PIVOT (COUNT(OrderID) FOR SalesChannel IN ([Online], [Store])) AS p
ORDER BY OrderYear;
Reasoning. COUNT returns 0, not NULL, for listed values with no rows, so the addition is safe here. The conditional-aggregation equivalent is Day 2’s SUM(CASE WHEN … THEN 1 ELSE 0 END).
Show solution 2
SELECT ProductCode,
COALESCE(CAST([Midlands Distribution Centre] AS varchar(10)), '—') AS Midlands,
COALESCE(CAST([Scotland Hub] AS varchar(10)), '—') AS Scotland
FROM (SELECT p.ProductCode, w.WarehouseName, s.QuantityOnHand
FROM Inventory.Stock AS s
JOIN Inventory.Product AS p ON p.ProductID = s.ProductID
JOIN Inventory.Warehouse AS w ON w.WarehouseID = s.WarehouseID) AS src
PIVOT (SUM(QuantityOnHand) FOR WarehouseName IN ([Midlands Distribution Centre], [Scotland Hub])) AS p
ORDER BY ProductCode;
Reasoning. Names with spaces must be bracketed. The — makes “no record” visible; a 0 would wrongly suggest “stocked but empty”. The source uses WarehouseName rather than WarehouseID, so WarehouseID must not be in the source — otherwise it would become a grouping column (B3).
Show solution 3
WITH CategoryTree AS
(
SELECT CategoryID, CategoryName AS TopCategory FROM Inventory.Category WHERE ParentCategoryID IS NULL
UNION ALL
SELECT c.CategoryID, t.TopCategory FROM Inventory.Category AS c JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID
),
src AS
(
SELECT t.TopCategory, CONCAT('Q', DATEPART(QUARTER, o.OrderDate)) AS Qtr,
l.Quantity * l.UnitPrice * (1 - l.DiscountPct) AS LineNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
JOIN CategoryTree AS t ON t.CategoryID = p.CategoryID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
),
Wide AS
(
SELECT TopCategory,
CAST([Q1] AS decimal(12,2)) AS Q1, CAST([Q2] AS decimal(12,2)) AS Q2,
CAST([Q3] AS decimal(12,2)) AS Q3, CAST([Q4] AS decimal(12,2)) AS Q4
FROM src
PIVOT (SUM(LineNet) FOR Qtr IN ([Q1], [Q2], [Q3], [Q4])) AS p
)
SELECT TopCategory, Qtr, NetSales
FROM Wide
UNPIVOT (NetSales FOR Qtr IN (Q1, Q2, Q3, Q4)) AS u
ORDER BY TopCategory, Qtr;
Result: 12 rows. The CASTs in Wide give the four columns an identical type, which UNPIVOT requires. The values are rounded to pence, so their sum matches the control total exactly only because each quarter total was rounded once; in general, rounding before unpivoting can create small differences.
Show solution 4
DECLARE @cols nvarchar(max),
@sql nvarchar(max);
SELECT @cols = STRING_AGG(CAST(QUOTENAME(WarehouseName) AS nvarchar(max)), N', ')
WITHIN GROUP (ORDER BY WarehouseName)
FROM Inventory.Warehouse;
SET @sql = N'
SELECT ProductCode, ' + @cols + N'
FROM (SELECT p.ProductCode, w.WarehouseName, s.QuantityOnHand
FROM Inventory.Stock AS s
JOIN Inventory.Product AS p ON p.ProductID = s.ProductID
JOIN Inventory.Warehouse AS w ON w.WarehouseID = s.WarehouseID) AS src
PIVOT (SUM(QuantityOnHand) FOR WarehouseName IN (' + @cols + N')) AS p
ORDER BY ProductCode;';
PRINT @sql;
EXEC sys.sp_executesql @sql;
Reasoning. The column list comes from Inventory.Warehouse, not from Inventory.Stock, so a new warehouse with no stock yet still gets a column (full of NULLs). To test without changing data permanently:
BEGIN TRANSACTION;
INSERT INTO Inventory.Warehouse (WarehouseID, WarehouseName, City) VALUES (3, N'Bristol Overflow', N'Bristol');
-- run the dynamic pivot here: a third column appears
ROLLBACK TRANSACTION; -- the warehouse disappears again
(Transactions are covered properly on Day 16; ROLLBACK undoes everything since BEGIN TRANSACTION.)
Show the stretch solution
DECLARE @cols nvarchar(max), @prev int, @last int, @growth nvarchar(max), @sql nvarchar(max);
SELECT @cols = STRING_AGG(CAST(QUOTENAME(CAST(OrderYear AS nvarchar(4))) AS nvarchar(max)), N', ')
WITHIN GROUP (ORDER BY OrderYear)
FROM (SELECT DISTINCT YEAR(OrderDate) AS OrderYear FROM Sales.SalesOrder WHERE OrderStatus <> 'Cancelled') AS y;
SELECT @last = MAX(OrderYear), @prev = MIN(OrderYear)
FROM (SELECT DISTINCT TOP (2) YEAR(OrderDate) AS OrderYear
FROM Sales.SalesOrder WHERE OrderStatus <> 'Cancelled'
ORDER BY YEAR(OrderDate) DESC) AS t;
SET @growth = N'CAST(100.0 * (' + QUOTENAME(CAST(@last AS nvarchar(4))) + N' - ' + QUOTENAME(CAST(@prev AS nvarchar(4)))
+ N') / NULLIF(' + QUOTENAME(CAST(@prev AS nvarchar(4))) + N', 0) AS decimal(6,1)) AS GrowthPct';
SET @sql = N'
WITH CategoryTree AS
(
SELECT CategoryID, CategoryName AS TopCategory FROM Inventory.Category WHERE ParentCategoryID IS NULL
UNION ALL
SELECT c.CategoryID, t.TopCategory FROM Inventory.Category AS c JOIN CategoryTree AS t ON c.ParentCategoryID = t.CategoryID
),
src AS
(
SELECT t.TopCategory, YEAR(o.OrderDate) AS OrderYear, l.Quantity * l.UnitPrice * (1 - l.DiscountPct) AS LineNet
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
JOIN CategoryTree AS t ON t.CategoryID = p.CategoryID
WHERE o.OrderStatus <> ''Cancelled''
)
SELECT TopCategory, ' + @cols + N', ' + @growth + N'
FROM src
PIVOT (SUM(LineNet) FOR OrderYear IN (' + @cols + N')) AS p
ORDER BY TopCategory;';
PRINT @sql;
EXEC sys.sp_executesql @sql;
Result: Accessories 10440.94 | 11731.09 | 12.4; Camping 27122.20 | 27572.05 | 1.7; Clothing 21049.80 | 24438.80 | 16.1 (pivoted values shown with four decimal places).
Reasoning. @prev and @last are integers from the data, but they still end up in the SQL text as identifiers, so they are quoted with QUOTENAME like any other identifier. TOP (2) with DISTINCT and ORDER BY returns the two latest years. If only one year existed, @prev would equal @last and growth would be 0 — a guard (IF @prev = @last …) would make the behaviour explicit.
H. Knowledge check
-
Understanding. What does
PIVOTgroup by? -
Query interpretation. A pivot of 2025 sales by channel returns 198 rows instead of 12. What is the most likely cause?
-
Debugging.
UNPIVOT (Val FOR Col IN (Email, Phone))fails with a type-conflict error. Why, and how do you fix it? -
Understanding. Why does unpivoting
EmailandPhonefor 40 customers return 66 rows, and how could you return all 80? -
Security. In a dynamic pivot, why must column names be wrapped in
QUOTENAME, and why should a user-supplied date not be concatenated into the SQL string?
Show the answers
-
Every column of its source that is neither the spreading column nor the aggregated value column.
-
An extra column (such as
OrderID) in the pivot source became an implicit grouping column. Narrow the source to grouping, spreading and value columns. -
Emailisnvarchar(255)andPhoneisvarchar(30); unpivoted columns must have identical types.CASTthem to the same type in a derived table first. -
UNPIVOTremoves NULL values (4 missing emails, 10 missing phones). UseCROSS APPLY (VALUES …), which keeps NULLs. -
QUOTENAMEturns any value into a single, safely escaped identifier, so a name containing spaces or]— or an injection attempt — cannot break out of the bracketed name. Values should be passed assp_executesqlparameters, which are treated strictly as data and are never executed as code (Day 28).
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
Far too many rows from PIVOT |
Extra source columns grouped implicitly | Narrow the source |
| Incorrect syntax near the keyword ‘FOR’ | Missing aggregate or alias in PIVOT |
PIVOT (SUM(x) FOR col IN (…)) AS p |
| Invalid column name ‘2025’. | Number used as identifier without brackets | [2025] |
| COUNT() is not allowed in the PIVOT operator* (syntax error) | COUNT(*) in PIVOT |
Count a non-NULL column |
| The type of column “X” conflicts with the type of other columns specified in the UNPIVOT list. | Different types | CAST all to one type first |
Rows missing after UNPIVOT |
NULL values dropped | CROSS APPLY (VALUES …) |
| Dynamic SQL fails with Incorrect syntax near … | A quote or comma problem in the generated text | PRINT @sql, copy it into a new window, fix, then fix the generator |
| Dynamic SQL truncated | nvarchar(4000) variables or non-N literals |
Use nvarchar(max) and N'…'; cast STRING_AGG input to nvarchar(max) |
| Must declare the scalar variable “@FromDate”. inside dynamic SQL | Outer variables are not visible to the dynamic batch | Pass them as sp_executesql parameters |
Cleanup. Day 11 is read-only (the Task 4 test insert is rolled back). Nothing to clean up. Keep RetailLab unchanged for Day 12, which adds a calendar table and a temporal table.
J. Completion checklist, recap and references
Completion checklist
- Worked example 2 returned 198 rows, and you can explain the implicit grouping.
- Worked example 5 returned 66, 80 and 66.
- Your lab cross-tab shows All categories = 63741.94 in both the static and dynamic versions.
- Tasks 1–4 match the success criteria.
- Your dynamic SQL quotes identifiers with
QUOTENAMEand passes values as parameters.
Recap
PIVOTturns values of a spreading column into columns with one aggregate; it groups by all remaining source columns — feed it a narrow source.- Listed values with no data give NULL; unlisted values are ignored.
- Conditional aggregation is an explicit, flexible alternative, especially for several measures or totals.
UNPIVOTturns columns into rows, requires identical types and drops NULLs;CROSS APPLY (VALUES …)keeps them.- Dynamic pivots build the column list from data with
STRING_AGG+QUOTENAME, run withsp_executesql, and pass values as parameters.
Next: Day 12 — Date and time queries, calendar tables, and system-versioned temporal tables.