Blog · How-to guides · Finance and FP&A teams
How to turn rows into columns in SQL: the SQL Server PIVOT operator, the portable conditional-aggregation version that runs in PostgreSQL, MySQL and SQLite, dynamic PIVOT for column values you do not know in advance, and UNPIVOT to go back. Every query runs on one ten-row sales table, with the result and the control-total check.
PIVOT in SQL Server turns the values of one column into new columns and aggregates another column into the cells. Sales stored as one row per region and quarter become one row per region with a column for each quarter. Where there is no PIVOT operator, conditional aggregation, one SUM(CASE WHEN ...) per column, gives the same result in any database.
SELECT row columns, [v1], [v2] FROM (SELECT row columns, pivot column, value column FROM table) AS src PIVOT (SUM(value column) FOR pivot column IN ([v1], [v2])) AS p;
Microsoft's PIVOT and UNPIVOT reference describes it as "turning the unique values from one column in the expression into multiple columns in the output", with aggregation run on the remaining values. Three parts matter: the aggregate (SUM here), the FOR column whose values become headers, and the IN list naming the headers you want.
A narrow table with three roles: the row key (Region), the column key (Quarter) and the value (Amount). Feed PIVOT a subquery that selects only those three. Every other column in the source, an invoice ID or a date, becomes an implicit grouping column. If your export is wide, prepare it for analysis first, and derive Quarter from the invoice date using your fiscal calendar, not the calendar year by default.
Table dbo.Sales, amounts in USD thousands, with two quarters that hold more than one row:
| Region | Quarter | Amount (USD thousands) |
|---|---|---|
| Northeast | Q1 | 120 |
| Northeast | Q1 | 30 |
| Northeast | Q2 | 140 |
| Northeast | Q3 | 155 |
| Northeast | Q4 | 170 |
| South | Q1 | 90 |
| South | Q2 | 95 |
| South | Q2 | 10 |
| South | Q3 | 100 |
| South | Q4 | 125 |
SELECT Region, [Q1], [Q2], [Q3], [Q4]
FROM (SELECT Region, Quarter, Amount FROM dbo.Sales) AS src
PIVOT (SUM(Amount) FOR Quarter IN ([Q1], [Q2], [Q3], [Q4])) AS p
ORDER BY Region;
Result, with the row and column totals added for the check:
| Region | Q1 | Q2 | Q3 | Q4 | Row total |
|---|---|---|---|---|---|
| Northeast | 150 | 140 | 155 | 170 | 615 |
| South | 90 | 105 | 100 | 125 | 420 |
| Total | 240 | 245 | 255 | 295 | 1,035 |
Northeast Q1 is 120 + 30 = 150 and South Q2 is 95 + 10 = 105: PIVOT aggregates duplicate cells, it does not drop them. We ran this query on SQL Server 2022.
The pivot must sum back to the source, the same control total idea as in control totals and identities:
SELECT SUM(Amount) FROM dbo.Sales; -- 1,035
The row totals give 615 + 420 = 1,035, and the column totals 240 + 245 + 255 + 295 = 1,035. If the pivot comes out short, a quarter is missing from the IN list: drop [Q4] from it and the 295 in Q4 disappears without an error.
The other failure is too many rows. Run the PIVOT on the whole table, with an InvoiceID column included, and every invoice becomes its own group: ten rows, each with one number and three NULLs. That is the most common PIVOT mistake, and it is why the source is a subquery.
The same result without PIVOT, in PostgreSQL, MySQL, SQLite, SQL Server and the cloud warehouses:
SELECT Region,
SUM(CASE WHEN Quarter = 'Q1' THEN Amount END) AS Q1,
SUM(CASE WHEN Quarter = 'Q2' THEN Amount END) AS Q2,
SUM(CASE WHEN Quarter = 'Q3' THEN Amount END) AS Q3,
SUM(CASE WHEN Quarter = 'Q4' THEN Amount END) AS Q4,
SUM(Amount) AS RowTotal
FROM Sales
GROUP BY Region
ORDER BY Region;
We ran it in SQLite: Northeast returns 150, 140, 155, 170 and 615; South returns 90, 105, 100, 125 and 420. It has two advantages over PIVOT: the row total comes in the same query, and the grouping is explicit, so extra columns cannot slip in.
In PostgreSQL the same columns are shorter with a FILTER clause:
SUM(Amount) FILTER (WHERE Quarter = 'Q1') AS q1
Leave out ELSE. Without it a region with no Q3 sales returns NULL in Q3, which says "no data". ELSE 0 returns 0, which looks like a quarter with sales of zero.
For the wider set of GROUP BY, CASE and window-function patterns, see SQL for data analysis.
PIVOT's IN list is fixed when the query is written. When the column values are not known in advance, such as a new quarter or a new product line, build the list at run time in SQL Server:
DECLARE @cols nvarchar(max), @sql nvarchar(max);
SELECT @cols = STRING_AGG(CAST(QUOTENAME(Quarter) AS nvarchar(max)), ', ')
WITHIN GROUP (ORDER BY Quarter)
FROM (SELECT DISTINCT Quarter FROM dbo.Sales) AS q;
SET @sql = N'SELECT Region, ' + @cols + N'
FROM (SELECT Region, Quarter, Amount FROM dbo.Sales) AS src
PIVOT (SUM(Amount) FOR Quarter IN (' + @cols + N')) AS p;';
EXEC sp_executesql @sql;
@cols becomes [Q1], [Q2], [Q3], [Q4] and the result matches the static query. STRING_AGG needs SQL Server 2017 or later. The CAST to nvarchar(max) matters: without it the result type is capped at 4,000 characters, and a long list fails. QUOTENAME wraps each value in brackets and escapes any bracket inside it, which is what keeps a value from being run as code. It accepts at most 128 characters and returns NULL beyond that.
UNPIVOT turns the quarter columns back into rows:
SELECT Region, Quarter, Amount
FROM dbo.SalesByQuarter
UNPIVOT (Amount FOR Quarter IN ([Q1], [Q2], [Q3], [Q4])) AS u;
Two things differ from the original table. The duplicates are gone: Northeast Q1 comes back as one row of 150, not 120 and 30. And NULLs vanish. Microsoft's reference says "NULL values in the input of UNPIVOT disappear in the output." Add a West row with Q2 empty and UNPIVOT returns 11 rows, not 12.
To keep the NULL rows, use CROSS APPLY with VALUES instead:
SELECT s.Region, x.Quarter, x.Amount
FROM dbo.SalesByQuarter AS s
CROSS APPLY (VALUES ('Q1', s.Q1), ('Q2', s.Q2), ('Q3', s.Q3), ('Q4', s.Q4)) AS x(Quarter, Amount);
That returns all 12 rows, with West Q2 as NULL.
ANY in place of a fixed list.crosstab function from the tablefunc extension.SUM(CASE WHEN ...).Covirage produces the region-by-quarter layout from your uploaded ledger without SQL, and its deterministic tools check that every row and column sums back to the source control total; the external AI model explains the pattern and never does the arithmetic. See how finance teams use it, and Power Pivot in Excel for the same idea across related tables in a workbook. For the spreadsheet version, see how to make a pivot table in Excel; for the cube tools that did this first, OLAP; for writing the query in plain English, text-to-SQL.
In SQL Server, Oracle and Snowflake use the PIVOT operator with an aggregate and an IN list of the values that become columns. In any database, use conditional aggregation: one SUM(CASE WHEN ...) per output column, grouped by the row key.
Not in a static query: the IN list must be written out. Build it at run time with STRING_AGG and QUOTENAME over the distinct values, then execute the statement with sp_executesql. Validate the values, because this is dynamic SQL.
Not as an operator. Use conditional aggregation with SUM(...) FILTER (WHERE ...), or the crosstab function from the tablefunc extension. MySQL also lacks PIVOT and uses SUM(CASE WHEN ...).
GROUP BY returns one row per group with values stacked in rows. PIVOT takes one of the grouping columns and spreads its values across columns. PIVOT is effectively GROUP BY plus a CASE per output column.