
简介这份PDF教程面向需要处理数据透视需求的数据库开发人员、报表工程师及SQL学习者系统讲解SQL Server中“行转列”的经典场景。内容以店铺一周收入表WEEK_INCOME为实例先展示常规二维查询结果再对比CASESUM函数写法的冗长进而引出SQL Server 2005及以上版本的PIVOT运算符。文档逐句拆解PIVOT(SUM(INCOME) FOR [week] IN(...))语法聚合函数决定转换后列的值FOR子句指定源列IN列表决定生成哪些目标列同时补充UNPIVOT说明与动态SQL适用边界。资源为单篇PDF文档共1个文件压缩包约66KB篇幅紧凑、重点突出。已有1530人学习下载。掌握后可快速写出报表所需的一周收入一行展示SQL也能理解传统写法与PIVOT的差异遇到列名动态变化时能判断是否该改用动态SQL对实际工作有直接参考价值。1. 为什么绕不开 PIVOT一周收入表转置背后的报表需求做数据库开发的人早晚会遇到一个很拧巴的需求数据表里明明是竖着存的报表上却要横着展示。拿原文那个例子来说WEEK_INCOME表里WEEK列存的是“星期一”到“星期日”一共七行领导要的却是一行七个字段的宽表。这是最典型的 SQL SERVER 行转列场景也是 PIVOT 这个关系运算符存在的理由。SQL Server 2005 起就提供了 PIVOT但很多从业者一直没把它用顺——MSDN 语法写得规范又抽象读三遍还是不知道那个FOR子句到底在干什么。这篇文章基于一张店铺一周收入表把 PIVOT 的语法拆成三步讲透同时覆盖 CASE WHEN 写法、UNPIVOT 反向操作、动态列名处理和后期的常见坑适合报表开发、数据抽取和 BI 取数场景下被行转列卡住的人。2. PIVOT 语法拆解从 MSDN 的抽象描述到可执行的三个步骤2.1 先建表插数把测试场景固定下来理解 PIVOT 之前先把操作对象准备好。下面这段建表和插入数据的脚本会在你的测试库里生成一张WEEK_INCOME表共七条记录分别代表一周七天CREATE TABLE WEEK_INCOME( WEEK VARCHAR(10), INCOME DECIMAL(10,2) ); INSERT INTO WEEK_INCOME SELECT 星期一, 1000 UNION ALL SELECT 星期二, 2000 UNION ALL SELECT 星期三, 3000 UNION ALL SELECT 星期四, 4000 UNION ALL SELECT 星期五, 5000 UNION ALL SELECT 星期六, 6000 UNION ALL SELECT 星期日, 7000;DECIMAL(10,2)是刻意选的类型——收入是金额用DECIMAL避免浮点误差这在报表场景里是基础习惯。UNION ALL比UNION合适因为这几行数据不存在重复UNION会多做一次去重排序纯属浪费。表建好后先跑一遍SELECT WEEK, INCOME FROM WEEK_INCOME确认拿到的是一个七行两列的窄表这是后续所有转换的“原料”。2.2 三步骤拆解聚合、FOR 子句、IN 列表到底各管什么PIVOT 的完整语法看起来复杂实际只干三件事。以下面这段代码为例SELECT [星期一], [星期二], [星期三], [星期四], [星期五], [星期六], [星期日] FROM WEEK_INCOME PIVOT( SUM(INCOME) FOR [WEEK] IN ([星期一], [星期二], [星期三], [星期四], [星期五], [星期六], [星期日]) ) AS TBL;第一步也是核心PIVOT(SUM(INCOME) FOR [WEEK] IN (...))。这里的语义是——把WEEK列的值逐个转换成目标列每个目标列的值用SUM(INCOME)计算。用大白话讲原来列里存的是“星期一”“星期二”这些字符串现在要让它们“站”到列名位置上去而每个新列对应的数值来自INCOME列按WEEK分组后的聚合结果。原文例子特意强调过如果表里有两个“星期一”一个 1000、一个 500SUM之后“星期一”这一列的值就是 1500这就是聚合函数的用武之地。第二步是数据来源FROM WEEK_INCOME。PIVOT 不是独立操作符它必须作用在一个已经存在的查询结果集上。在实际开发中这里经常不是一张裸表而是一个子查询比如FROM (SELECT ... FROM ... WHERE ...) AS SRC。有一点必须记住子查询必须加别名否则 SQL 解析器直接报错。这属于语法硬性要求记不住就踩坑。第三步是最外层的SELECT [星期一], [星期二], ...。PIVOT 执行完之后生成的结果集列名就是IN列表里定义的这些中括号名称外层 SELECT 负责决定最终要展示哪些列。这里有个容易被忽略的点用*也行——当IN列表很长、而你确实需要全部列时SELECT *能少写很多字符但如果IN列表只想转“星期一”到“星期五”外层 SELECT 里却写了“星期六”查询直接报错因为生成的中间结果集里根本没有这个列。2.3 聚合函数的选择逻辑SUM、AVG、MAX 不是随便挑的PIVOT 里那个聚合函数本质上是新列取值的计算规则。同一个表结构用不同聚合结果完全不同。拿一周收入表来说如果数据没有重复的星期值SUM和MAX效果一样都是把那个唯一的INCOME值带过来但如果业务数据里有重复比如一条订单表里一个客户名下有多张订单要把“订单状态”转成列、把“订单金额”填进去用SUM就是在做汇总用MAX就是在取最大单笔金额。我的经验是做报表行转列时90% 的情形是SUM只有明确要取“存在性最大值”或者“最新一条记录的值”时才考虑MAX、MIN。AVG用得极少它只在你需要把一列数值平均到每个转置单元时才有意义。选聚合函数之前先问自己一个问题转置后的每个单元格容纳的是一个集合的汇总值还是唯一值如果是汇总SUM如果是唯一值但数据不干净、有多行重复先ROW_NUMBER()去重再转而不是靠MAX碰运气。2.4 序列顺序与中括号为什么 WEEK 要加方括号FOR [WEEK]里的方括号是 SQL Server 用来包裹标识符的凡是列名里带空格、首字符是数字、或者恰好是保留字的情况都必须用方括号。WEEK本身不是保留字不加也能跑通但统一加上是防止哪天列名改动后语法翻车。IN列表里的“星期一”到“星期日”也带方括号原因不同——它们是原来WEEK列里的文本值转置后要变成合法列名。中文做列名在 SQL Server 里合法但输出结果里这些列名会以中文字段名出现后续如果要把结果集导出到 Excel 或者继续 JOIN注意字段引用方式即可。3. 从 CASE WHEN 到 PIVOT两种写法的功能对比与改写实践3.1 CASE WHEN 方案没有 PIVOT 的年代怎么转在 SQL Server 2005 之前行转列靠的是CASE WHEN加聚合函数硬拼SELECT SUM(CASE WEEK WHEN 星期一 THEN INCOME END) AS [星期一], SUM(CASE WEEK WHEN 星期二 THEN INCOME END) AS [星期二], SUM(CASE WEEK WHEN 星期三 THEN INCOME END) AS [星期三], SUM(CASE WEEK WHEN 星期四 THEN INCOME END) AS [星期四], SUM(CASE WEEK WHEN 星期五 THEN INCOME END) AS [星期五], SUM(CASE WEEK WHEN 星期六 THEN INCOME END) AS [星期六], SUM(CASE WEEK WHEN 星期日 THEN INCOME END) AS [星期日] FROM WEEK_INCOME;这个写法要拆开理解。CASE WEEK WHEN 星期一 THEN INCOME END的意思是当WEEK值是“星期一”时返回INCOME其他情况返回 NULL。SUM忽略 NULL所以每一列只汇总自己那一类的值。如果表里没有分组字段整表聚合后自然只有一行结果这就是转置后的宽表。这个方案的优点是对 SQL 版本要求低2000 都能跑缺点是列一多就非常啰嗦如果转置的目标列有 30 个CASE要写 30 遍且将来加一列要改 SQL 文本。还有一个隐患经常被忽略CASE里如果不写ELSE 0不符合条件的行返回 NULLSUM忽略 NULL效果刚好但如果有人“手欠”加了ELSE 0SUM会把所有本不该计入这列的行也算成 0导致结果集里出现大量无意义的零值列。这个坑我在真实项目里见过不止一次。3.2 PIVOT 版本同样的结果更短的代码把上面的 CASE 版本改写成 PIVOT就是文章开头那段代码SELECT [星期一], [星期二], [星期三], [星期四], [星期五], [星期六], [星期日] FROM WEEK_INCOME PIVOT( SUM(INCOME) FOR [WEEK] IN ([星期一], [星期二], [星期三], [星期四], [星期五], [星期六], [星期日]) ) AS TBL;对比两种写法PIVOT 的优势不只是短。IN列表就是一份“列清单”增删一个目标列只需改列表里的一个元素而 CASE 版本要去改一整句表达式。从维护角度讲PIVOT 的可读性远胜。但需要说明PIVOT 在 SQL Server 2005 及以上才支持如果维护的是老系统比如 SQL Server 2000CASE 方案仍然是唯一解。判断用哪种第一看版本第二看列数量——两类列在 5 个以下CASE 也谈不上多痛苦10 个以上PIVOT 优势就非常明显了。3.3 别漏掉分组字段PIVOT 对多余列的隐性分组行为这里有个坑值得单独说。如果WEEK_INCOME表里除了WEEK和INCOME还有一个SHOP_ID店铺字段直接拿这张表去 PIVOT结果会出乎意料——PIVOT 会把没有出现在聚合函数参数里、也没有出现在FOR子句里的列自动当作隐式分组列。-- 假设表多了一列 SHOP_ID SELECT SHOP_ID, [星期一], [星期二] FROM WEEK_INCOME_WITH_SHOP PIVOT( SUM(INCOME) FOR [WEEK] IN ([星期一], [星期二]) ) AS TBL;这段代码返回的不是一行而是每个SHOP_ID对应一行。如果这不是你想要的解决办法是先子查询把不需要的列剔除SELECT [星期一], [星期二] FROM ( SELECT WEEK, INCOME FROM WEEK_INCOME_WITH_SHOP ) AS SRC PIVOT( SUM(INCOME) FOR [WEEK] IN ([星期一], [星期二]) ) AS TBL;这个细节在官方文档里没有高亮但实际报表开发中非常容易中招。我的原则是写 PIVOT 时永远先做一层子查询明确列出参与转置的源头列宁可多写几行子查询也不让 PIVOT 的隐式分组逻辑替你“决定”结果集形态那样排查起来太痛苦了。3.4 什么时候不该用 PIVOT边界条件要清楚PIVOT 不是万能的。目标列名不确定比如“过去 30 天每天的销售额”列名是日期每天在变、数据量特别大、或者转置后还需要继续做复杂关联这些场景下 PIVOT 写起来非常别扭。动态列名要用动态 SQL 拼这本身就是另一种复杂度大表 PIVOT 会消耗较多内存做排序和聚合而如果转置结果还要 JOIN 其他表宽表本身不如窄表好关联。判断标准就一条列清单是否静态。静态列清单用 PIVOT 省事动态列清单提前评估动 SQL 的成本。后面章节会详细讲动态列名怎么处理。4. UNPIVOT 反向操作与动态列名把宽表还原成窄表4.1 UNPIVOT 的语法与使用场景行转列的逆向需求同样常见一张宽表里存着“星期一”到“星期日”七个字段你要把它还原成七行记录这在 ETL 和数据清洗里是家常便饭。SQL Server 提供的操作符是 UNPIVOT-- 先造一张宽表 SELECT [星期一]1000, [星期二]2000, [星期三]3000, [星期四]4000, [星期五]5000, [星期六]6000, [星期日]7000 INTO WEEK_INCOME_WIDE; -- 列转行 SELECT WEEK, INCOME FROM WEEK_INCOME_WIDE UNPIVOT( INCOME FOR WEEK IN ([星期一], [星期二], [星期三], [星期四], [星期五], [星期六], [星期日]) ) AS TBL;UNPIVOT 的语法结构和 PIVOT 镜像对称INCOME FOR WEEK IN (...)表示把IN列表里的各列还原成WEEK列的值原来列里的数值放进INCOME列。执行后得到的就是七行两列的窄表。两个细节要注意。第一UNPIVOT 要求参与还原的列的数据类型一致如果宽表里“星期一”是DECIMAL而“星期日”是INTSQL Server 会报类型转换错误写之前先统一列类型。第二UNPIVOT 会自动忽略原表中的 NULL 值——如果某个日期的收入是 NULL还原后的窄表里根本没有这一行。这个行为有时是优点有时会让人困惑视业务而定但必须知道它的存在。4.2 列名不确定时的动态 SQL 方案实际业务中最烦人的不是 PIVOT 写不出来而是列名天天变。比如“看最近 7 天的每日销售额”前天有数据、昨天数据缺失、明天要动态计算日期范围列清单每次都不一样。PIVOT 的IN列表必须显式写出列名不接受变量直接替换所以要拼动态 SQLDECLARE cols NVARCHAR(MAX) N; DECLARE sql NVARCHAR(MAX); -- 用 FOR XML PATH 拼接列名逗号分隔 SELECT cols cols QUOTENAME(WEEK) , FROM (SELECT DISTINCT WEEK FROM WEEK_INCOME) AS T; SET cols LEFT(cols, LEN(cols) - 1); -- 去掉末尾逗号 SET sql N SELECT cols N FROM WEEK_INCOME PIVOT( SUM(INCOME) FOR WEEK IN ( cols N) ) AS TBL;; EXEC sp_executesql sql;这段代码里有三个关键点。QUOTENAME(WEEK)会自动把列名用方括号包起来防止列名带空格或特殊字符时拼出非法 SQL用FOR XML PATH()做字符串拼接是 SQL Server 里最通用的方式比CURSOR循环效率高得多2008 之后也支持STRING_AGG但FOR XML PATH兼容性最稳最后必须用sp_executesql执行拼接好的动态语句不能用简单的EXEC因为sp_executesql还能传参数。动态 SQL 有个显而易见的风险SQL 注入。如果列名来源是外部输入或者用户自定义字段拼接前要做白名单校验。内部报表场景里列名通常来自数据库本身风险相对可控但写入正式代码前仍要养成打印 SQL 文本检查的习惯——把PRINT sql放在EXEC之前先看生成的语句是否正确再决定是否执行。4.3 动态列名的缓存与性能考量动态 PIVOT 每次执行都要重新生成 SQL、重新编译性能比静态 PIVOT 差是正常的。如果报表每天都在跑且列清单相对稳定比如只是日期范围在变可以考虑把列清单结果缓存到一张配置表里当天第一次生成后复用只有列清单确实变化时才重新拼接。还有一个技巧拼接列名时用SELECT DISTINCT WEEK从源表取如果源表有索引这个查询会很快如果数据量上百万DISTINCT可能会成为瓶颈这时候宁可维护一张“周几映射表”来驱动列清单也别让DISTINCT去扫全表。5. PIVOT 避坑指南聚合 NULL、类型隐式转换与排序语义5.1 转置结果为 NULL报表里一堆空白单元格现象PIVOT 执行成功结果集里某些列的值是 NULL报表导出后大段空白。原因源表里该分组下本来就有 NULLSUM对 NULL 的处理结果是 NULL不是 0。解决PIVOT 之后在外层用 ISNULL 包裹或者在源子查询阶段就把 NULL 转成 0。SELECT ISNULL([星期一], 0) AS [星期一], ISNULL([星期二], 0) AS [星期二] FROM WEEK_INCOME PIVOT( SUM(INCOME) FOR [WEEK] IN ([星期一], [星期二]) ) AS TBL;这里的关键是理解 NULL 和 0 的语义差别。业务上“没有记录”有时不等于“金额是 0”直接在 PIVOT 外层套ISNULL会掩盖数据缺失的事实。我的习惯是先保持 NULL 跑一版确认哪些天确实没有收入再做ISNULL——如果某天在源表里压根没有数据ISNULL补 0 是合理的如果源表有数据但值是 NULL需要回到数据源头去查为什么是 NULL而不是直接把报表层填 0 糊弄过去。5.2 报错“列名无效”或“语法错误”中括号与别名问题现象PIVOT 语句执行报错提示找不到列或者提示PIVOT附近有语法错误。原因大概率是IN列表里的列名与源列值不完全一致比如源表里存的是带空格的“星期一 ”IN列表里写成了“星期一”或者子查询没有写别名。解决先跑一遍源查询用SELECT DISTINCT WEEK FROM ...看真实的列值内容再复制到IN列表子查询后面必须加别名这是 PIVOT 的硬性语法要求。-- 错误写法 SELECT [星期一] FROM (SELECT WEEK, INCOME FROM WEEK_INCOME) -- 没有别名 PIVOT(SUM(INCOME) FOR WEEK IN ([星期一])) AS TBL; -- 正确写法 SELECT [星期一] FROM (SELECT WEEK, INCOME FROM WEEK_INCOME) AS SRC -- 别名必须有 PIVOT(SUM(INCOME) FOR WEEK IN ([星期一])) AS TBL;5.3 隐式类型转换引发的金额不准现象INCOME列是VARCHAR类型PIVOT 里写SUM(INCOME)结果小数位丢失或报错转换失败。原因SUM对字符串列会做隐式转换转换规则不可控1000.5和1000.5的处理结果可能完全不同。解决建表时就把金额列定义为DECIMAL如果源数据已经是VARCHAR先在子查询里做CAST(INCOME AS DECIMAL(10,2))再进 PIVOT。SELECT [星期一] FROM ( SELECT WEEK, CAST(INCOME AS DECIMAL(10,2)) AS INCOME FROM WEEK_INCOME ) AS SRC PIVOT(SUM(INCOME) FOR WEEK IN ([星期一])) AS TBL;这个坑最隐蔽的地方在于小数据量测试时看不出问题一旦数据量大、字符串内容不规整SUM中途报错或者算出荒谬结果排查起来费时费力。建表时类型规范是治本子查询 CAST 是治标两者配合最稳。5.4 结果排序与原始数据顺序无关现象觉得 PIVOT 结果集的列顺序应该跟表里数据的顺序一致结果发现乱了。原因SQL Server 不保证 PIVOT 结果列的排序与源表数据插入顺序相关列顺序由IN列表顺序决定。解决IN列表里的列名想按什么顺序展示就按什么顺序写不要依赖表里的记录顺序。比如想让“星期日”排最后IN列表最后写“星期日”即可。如果列名是动态生成的排序逻辑要体现在生成cols字符串的ORDER BY里。5.5 PIVOT 查询里使用别名但忘了引用现象PIVOT 执行成功但外层 SELECT 引用列时报“列名不存在”。原因外层 SELECT 引用的列名必须是IN列表里定义的新列名而不是源表的旧列名。有人会习惯性写SELECT WEEK, [星期一]此时WEEK这个源列已不存在于 PIVOT 的中间结果集中。解决明确 PIVOT 输出列的构成——只有IN列表里的新列名和隐式分组列源列不再可用。想保留WEEK做后续计算要么在 PIVOT 前先复制一份到其他列名要么在 PIVOT 后用 UNPIVOT 再还原设计阶段要想清楚。6. 拿真实报表练手把 PIVOT 变成肌肉记忆的验证路径理解了 PIVOT 的语法和坑之后真正拉开差距的是动手验证。我每次接触新的行转列需求固定按四个步骤走第一步用SELECT把源数据查出来确认窄表长什么样哪些列的值将来要做列名第二步确定聚合函数用脑内模拟一遍每个转置单元应该填什么值第三步静态列清单用固定 PIVOT 写动态列清单走sp_executesql拼装先PRINT sql再执行第四步验证结果——比对 PIVOT 输出和 CASE WHEN 的输出是否一致。我的验证习惯是故意制造一个“脏数据”场景在WEEK_INCOME里插入重复的“星期一”两条一条 1000、一条 500然后分别跑 CASE 版和 PIVOT 版看两个结果是否都是 1500。这一步能同时验证聚合逻辑和 NULL 处理比对着语法文档空想有效得多。-- 故意造一条重复数据验证聚合 INSERT INTO WEEK_INCOME VALUES (星期一, 500); SELECT SUM(CASE WEEK WHEN 星期一 THEN INCOME END) AS CASE_Value FROM WEEK_INCOME; -- 期望 1500 SELECT [星期一] FROM WEEK_INCOME PIVOT(SUM(INCOME) FOR WEEK IN ([星期一])) AS TBL; -- 期望 1500如果这两个查询结果不一致说明对聚合函数的理解或者数据本身有问题先去查源数据而不是改 SQL。等这两条跑平了再扩展成完整七天对照业务报表确认每个单元格的数值和业务口径对得上。从那以后我每次写 PIVOT 都强制先跑一遍“源表 → DISTINCT 列值 → 手工算聚合值 → 上 PIVOT”四步流程走完基本不会翻车。希望你也能按这个路径把 PIVOT 用顺手少走弯路。本文还有配套的精品资源点击获取