在做数据统计和报表的时候,我们经常会碰到这样的需求:先用GROUP BY把数据按某个维度分组汇总,得到多个统计指标列,然后业务方又要求把这些指标列变成一行一行的数据,方便导入其他系统或者在页面上逐条展示。这种列转行的操作在SQL Server里有专门的解决方案,就是UNPIVOT运算符。它和PIVOT是一对反方向的运算符,PIVOT把行变成列,UNPIVOT把列变成行,两者配合可以解决绝大多数的数据结构转换问题。本文就从实际场景出发,详细讲讲如何在分组统计的基础上完成列转行。

一、UNPIVOT的基本语法和工作原理
UNPIVOT是SQL Server 2005开始引入的表运算符,它的作用是将列值转换为行值。理解它的工作原理,最好的方式是先看一个简单的例子。假设有下面这样一张销售汇总表:
CREATE TABLE SalesSummary (
Region NVARCHAR(50), -- 区域
Q1Sales DECIMAL(12,2), -- 一季度销售额
Q2Sales DECIMAL(12,2), -- 二季度销售额
Q3Sales DECIMAL(12,2), -- 三季度销售额
Q4Sales DECIMAL(12,2) -- 四季度销售额
);
INSERT INTO SalesSummary VALUES
(N'华东', 120000, 135000, 142000, 158000),
(N'华南', 98000, 105000, 110000, 125000),
(N'华北', 88000, 92000, 96000, 102000);
现在要求把四个季度的列转成行,每个季度占一行。用UNPIVOT实现的语句如下:
SELECT Region, Quarter, SalesAmount
FROM SalesSummary
UNPIVOT (
SalesAmount FOR Quarter IN (Q1Sales, Q2Sales, Q3Sales, Q4Sales)
) AS u;
执行后原来的3行数据会变成12行,每一行代表某个区域某个季度的销售额。语法结构里,SalesAmount是值列,用来存放原来各列中的具体数值;Quarter是新的分类列,用来存放原来的列名;IN列表中写明要转换的列。注意一点,IN列表中列名的拼写必须和表结构完全一致,否则会直接报错。
UNPIVOT在底层执行时,实际上是扫描IN列表中的每一个列,为每个匹配的列生成一行输出。如果某个值是NULL,UNPIVOT会自动过滤掉这一行,这是它和UNION ALL写法的一个重要区别,后面会详细对比。
二、先分组再列转行的完整实现
实际业务中,原始数据往往是明细表,需要先做GROUP BY汇总,再对汇总结果做列转行。这两步可以合并到一条语句中完成,把分组查询作为UNPIVOT的输入源。下面用一个订单明细表的例子来演示完整流程。
假设有一张订单明细表,包含区域、产品类别和金额三个字段,要求统计每个区域三种类别的销售总额,然后转换成行式结构:
-- 第一步:创建测试数据
CREATE TABLE OrderDetail (
OrderID INT,
Region NVARCHAR(50),
Category NVARCHAR(50),
Amount DECIMAL(12,2)
);
INSERT INTO OrderDetail VALUES
(1, N'华东', N'电子产品', 5000),
(2, N'华东', N'电子产品', 3200),
(3, N'华东', N'食品', 1800),
(4, N'华南', N'食品', 2600),
(5, N'华南', N'服装', 4200),
(6, N'华北', N'电子产品', 3900);
-- 第二步:先分组汇总,再用UNPIVOT列转行
SELECT t.Region, t.Category, t.TotalAmount
FROM (
SELECT Region,
SUM(CASE WHEN Category = N'电子产品' THEN Amount ELSE 0 END) AS Electronics,
SUM(CASE WHEN Category = N'食品' THEN Amount ELSE 0 END) AS Food,
SUM(CASE WHEN Category = N'服装' THEN Amount ELSE 0 END) AS Clothing
FROM OrderDetail
GROUP BY Region
) AS t
UNPIVOT (
TotalAmount FOR Category IN (Electronics, Food, Clothing)
) AS u;
这个例子展示了典型的三段式结构:最内层是条件聚合完成GROUP BY汇总,中间得到一张宽表,外层UNPIVOT把宽表摊平成窄表。输出结果中,Category列的值就是内层查询定义的列别名,所以如果想让最终显示的类别名称更友好,可以在内层子查询里直接把列别名写成中文或者业务需要的名称,比如AS 电子产品,这样UNPIVOT之后Category列显示的就是中文了。
还有一种常见写法是先在CTE里完成分组,再对CTE做UNPIVOT,逻辑上更清晰:
WITH Summary AS (
SELECT Region,
SUM(CASE WHEN Category = N'电子产品' THEN Amount ELSE 0 END) AS Electronics,
SUM(CASE WHEN Category = N'食品' THEN Amount ELSE 0 END) AS Food
FROM OrderDetail
GROUP BY Region
)
SELECT Region, Category, TotalAmount
FROM Summary
UNPIVOT (TotalAmount FOR Category IN (Electronics, Food)) AS u;
这种写法和子查询方式在性能上没有本质区别,选择哪种主要看团队的习惯和语句的复杂程度。当汇总逻辑比较复杂时,CTE的可读性优势会更明显。
三、UNPIVOT与UNION ALL写法的对比
在UNPIVOT出现之前,实现列转行一般用多个SELECT加UNION ALL的方式。两种写法各有适用场景,有必要仔细对比一下。先看UNION ALL版的实现:
SELECT Region, N'Q1' AS Quarter, Q1Sales AS SalesAmount FROM SalesSummary UNION ALL SELECT Region, N'Q2', Q2Sales FROM SalesSummary UNION ALL SELECT Region, N'Q3', Q3Sales FROM SalesSummary UNION ALL SELECT Region, N'Q4', Q4Sales FROM SalesSummary;
从执行计划来看,UNION ALL写法会对源表扫描多次,列越多扫描次数越多;而UNPIVOT通常只需要扫描一次数据,然后通过一个展开运算生成多行。在源表数据量大、需要转换的列比较多的情况下,UNPIVOT的性能优势比较明显。
两者在NULL处理上也不同。UNPIVOT会自动丢弃值为NULL的行,而UNION ALL会保留NULL值行。如果业务上需要保留NULL的记录(比如某季度没有数据也要显示一行),就要用UNION ALL,或者先把NULL替换成默认值再交给UNPIVOT处理,例如用ISNULL(Q1Sales, 0)先做一次转换。
灵活性方面,UNION ALL更有优势。UNPIVOT要求IN列表中所有列的数据类型必须一致,如果不一致会报错,必须先用CAST或CONVERT统一类型;UNION ALL的各个分支可以各自做类型转换,写起来更自由。另外UNPIVOT的IN列表不支持动态列名,列的数量在编译时就固定了,如果列名来自用户输入或需要动态生成,就只能走动态SQL拼装的路线。
四、常见问题与处理技巧
使用UNPIVOT时经常会踩到几个坑,这里逐一说明。第一个是类型不一致的问题。比如汇总表中有的列是INT,有的是DECIMAL,直接放进同一个IN列表会报类型转换错误。解决办法是在内层查询里统一做类型转换:
SELECT Region, Metric, Value
FROM (
SELECT Region,
CAST(OrderCount AS DECIMAL(12,2)) AS OrderCount,
CAST(TotalAmount AS DECIMAL(12,2)) AS TotalAmount,
CAST(AvgPrice AS DECIMAL(12,2)) AS AvgPrice
FROM RegionStats
) AS t
UNPIVOT (Value FOR Metric IN (OrderCount, TotalAmount, AvgPrice)) AS u;
第二个是动态列的场景。当要转换的列不固定,比如表结构中按月份动态生成的列,可以查询系统视图获取列名后拼动态SQL:
DECLARE @cols NVARCHAR(MAX), @sql NVARCHAR(MAX);
SELECT @cols = STRING_AGG(QUOTENAME(COLUMN_NAME), ',')
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'SalesSummary'
AND COLUMN_NAME LIKE 'Q%Sales';
SET @sql = N'SELECT Region, Quarter, SalesAmount
FROM SalesSummary
UNPIVOT (SalesAmount FOR Quarter IN (' + @cols + N')) AS u;';
EXEC sp_executesql @sql;
这里用QUOTENAME给列名加上方括号,可以避免列名中包含特殊字符导致的语法问题,STRING_AGG是SQL Server 2017之后可用的函数,老版本可以用FOR XML PATH的方式替代拼接。
最后要注意输出列名的问题。UNPIVOT生成的分类列直接使用原列名作为值,如果希望展示更友好的名称,除了前面说的改内层别名,也可以在外层再套一层查询做名称映射,用CASE表达式把英文列名翻译成中文标签,这样语句层次清晰,维护起来也更方便。掌握这些细节后,分组加列转行的需求基本都能优雅地处理掉。
SQL ServerUNPIVOT列转行修改时间:2026-09-12 23:30:42