数据处理基本功:合并与拼接的三种方式与避坑指南
发布时间:2026/9/8 0:49:53 锦皓数字建站

开篇数据处理绕不开的合并与拼接前几天帮同事排查一个报表问题数据源是三张表一张订单表、一张用户表、一张区域表。他在 Excel 里手动 VLOOKUP 折腾了一下午结果还是对不齐最后发现是有几条订单的用户 ID 在用户表里根本不存在而他在 VLOOKUP 之前没有做任何核对。我当时就跟他说了一句话你这不是 Excel 操作问题是“合并与拼接”这个基本功没理顺。数据处理这件事不管你用的是 Python、SQL、R 还是纯手工表格合并与拼接永远是绕不开的那道坎。原因很简单现实世界的数据从来不是整整齐齐躺在同一张表里的。订单数据在订单系统里用户数据在用户系统里区域数据可能来自第三方维护的维度表你要做分析首先就得把这些分散的数据凑到一块儿。这个“凑到一块儿”的过程就是合并与拼接。这篇是数据处理系列的开篇我会把合并与拼接这件事彻底拆开讲清楚纵向拼接和横向拼接有什么区别、什么时候该用 join、什么时候该用 concat、为什么 merge 之后行数会变多、大批量数据合并时内存爆掉怎么处理、以及我在实际项目中踩过的各种坑。不管你用的是 pandas、SQL 还是其他工具底层的思路都是相通的。看完这篇你至少能少走一半弯路。1. 为什么说合并与拼接是数据处理的基本功1.1 一个真实的工作场景报表数据为什么总是对不齐先把前面那个报表问题的细节补全。同事要出的报表是“各地区月度订单金额排行”逻辑上很简单订单表按照地区 ID 分组求和再把地区 ID 翻译成地区名称。但实际做的时候麻烦接踵而至。订单表里有一千多万行地区 ID 直接嵌在订单记录里地区表是一张独立维护的维度表只有几百行。同事先在 Excel 里用 VLOOKUP 把地区名称填到订单表后面然后做数据透视表汇总。听着好像也还行但他漏了一个关键点订单表里有几个地区 ID 是 0 和 -1这在业务里代表“未知区域”和“测试订单”地区表里根本没有这两条记录。VLOOKUP 查不到就返回了 #N/A透视表把这几个 #N/A 单独列了一行报表上就出现了一个莫名其妙的“错误区域”。这个问题的本质不是 VLOOKUP 用得不熟而是他完全没有意识到两表合并时键值对不上是常态不是异常。一个合格的数据处理流程必须有意识地处理这种“匹配不上”的情况。你是要在结果里保留它、标记它、还是直接丢掉它这是业务决策不是工具问题。1.2 合并与拼接的三张面孔行拼接、列拼接、键连接很多人把“合并与拼接”笼统地理解为“把两个表拼在一起”但实际操作中这个动作有三副完全不同的面孔选错了就是把数据搞坏的开端。第一张面孔是行拼接也就是把两张结构完全相同的表上下摞起来。典型的场景是上个月的数据存一个文件这个月的数据存另一个文件两个文件的列名、列顺序、数据类型完全一致你要做的是把所有记录纵向堆叠成一张大表。pandas 里的concat(axis0)、SQL 里的UNION ALL、Excel 里把两个表复制粘贴到同一个 Sheet都是这个逻辑。第二张面孔是列拼接也就是把两张表左右并排放到一起但行与行之间是一一对应的。举个例子一个模型的特征工程里A 文件保存了样本 ID 和特征 1 到 100B 文件保存了同一个样本集的样本 ID 和特征 101 到 200两张表的行顺序完全一致直接左右拼起来就行。这个场景用concat(axis1)最方便。第三张面孔是键连接这才是大家平时说的“join”。它的核心不是“拼”而是“匹配”根据一个或多个键值把两张表里满足条件的行配对起来。前面订单表和地区表的例子就是键连接。键连接的复杂性远远超过前两种因为它涉及一对多、多对多、缺失匹配这些逻辑问题。区分这三张面孔是基本功中的基本功。搞清楚你是要“上下堆”“左右拼”还是“按钥匙配对”自然就知道该用什么函数、什么语法也不会出现“明明拼好了却数据错位”这种诡异问题。1.3 为什么绕不开数据源系统的天然分散性再往深处想一层为什么合并与拼接在所有数据处理任务里都避不开答案是任何一家公司、任何一个业务系统数据天然就是分散的。订单系统只管订单不会关心用户注册了多久用户系统只管账号资料不会管某个用户买了什么日志系统记录的是一堆原始事件跟业务维度的关系需要你自己去关联。这是一种“范式化”的设计哲学每个系统只维护自己最核心的数据避免重复存储带来的不一致和浪费。但这种设计对分析人员来说就是不友好。你要做用户生命周期分析就得把订单数据、用户注册数据、活跃日志数据拼在一起你要做商品推荐就得把浏览记录、购买记录、商品属性表合并。数据分析的大部分时间其实不是在“分析”而是在把数据从分散形态整理成可供分析的宽表形态。这个“整理”动作的核心就是合并与拼接。这也解释了为什么各种数据处理工具都把合并拼接能力放在最核心的位置pandas 的merge和concat、SQL 的JOIN和UNION、R 的merge和rbind、甚至 Excel 的VLOOKUP和 Power Query 里的“合并查询”全部都是在解决同一个问题。把这个基本功练扎实了迁移到任何工具上都只是语法差异而已。2. 三种合并方式的使用边界与选择2.1 按行拼接纵向合并的场景与细节行拼接是三种方式里最“无脑”的但无脑不等于没有坑。先记一个判断标准只有当两张表的列结构完全一致时行拼接才有意义。我在处理月度数据归档时经常这么用。假设一月份的数据长这样import pandas as pd df_jan pd.DataFrame({ order_id: [A001, A002, A003], amount: [100, 250, 80], user_id: [U01, U02, U01] }) df_feb pd.DataFrame({ order_id: [B001, B002], amount: [300, 120], user_id: [U03, U02] })两个 DataFrame 的列名、列顺序、类型都一致直接纵向拼接df_all pd.concat([df_jan, df_feb], axis0, ignore_indexTrue)这里有个关键参数ignore_indexTrue。如果不设这个参数拼接后的结果会保留原始的行索引0,1,2,0,1这会导致后续df_all.loc[0]返回两行很多诡异的 bug 就是这么来的。设成True之后索引会重新编号为 0 到 4干净清爽。另一个细节是axis0是默认值也就是不写axis参数时concat默认按行拼接。但明确写出来有两个好处一是代码可读性更高别人一眼知道你的意图二是避免某些情况下参数被默认值误导。如果两张表的列名不完全一致呢concat不会报错它会取所有列的并集缺失的部分填充NaN。这个行为有时候是你想要的有时候不是。如果你确定两张表应该有完全相同的列但结果是并集且出现了 NaN那说明上游数据大概率有问题值得停下来检查一下而不是默默往下走。2.2 按列拼接横向扩展的典型应用列拼接的场景没有行拼接那么常见但在特征工程和结果对比里经常出现。它的前提是两张表的行数相同且行顺序一一对应否则就是一场灾难。举一个我自己做特征工程时的例子。当时我在处理用户行为特征一批特征是从登录日志里统计出来的另一批特征是从购买记录里统计出来的。两份结果都按用户 ID 排好序因为我用了相同的分组排序逻辑所以第 i 行的用户 ID 是一致的。这时候直接横向拼接df_features pd.concat([df_login_features, df_purchase_features], axis1)axis1表示沿着列方向拼接效果就是把右边的表“粘”在左边表的右边。但这里我必须强调一个安全习惯在横向拼接之前永远先做一个校验——确认两张表的主键列完全一致。做法也很简单assert (df_login_features[user_id] df_purchase_features[user_id]).all()如果这个断言抛异常说明两份数据的行顺序已经错位了这时候直接拼接得到的结果全是错的。我见过不下五次这类问题两个人都按照同样的逻辑处理数据但其中一个人中途删过几条脏数据另一个人没删最后行数对不上拼接结果错得离谱。加一行断言两秒钟的事能省掉一整天的排查时间。2.3 键连接表关联与 concat 的本质区别键连接和上面两种拼接有本质区别它不关心“位置”只关心“键值”。也就是说两张表里每一行的物理位置不重要重要的是它们的键值能不能对得上。这也意味着它更灵活但也更容易出现行数变化。在 pandas 里键连接的主入口是merge方法。以下面这个订单表和用户表为例df_orders pd.DataFrame({ order_id: [A001, A002, A003], user_id: [U01, U02, U03], amount: [100, 250, 80] }) df_users pd.DataFrame({ user_id: [U01, U02, U04], user_name: [张三, 李四, 王五], city: [北京, 上海, 广州] })合并这两张表的意图是给每条订单补上用户姓名和城市。用 mergedf_result df_orders.merge(df_users, onuser_id, howleft)结果如下order_iduser_idamountuser_namecityA001U01100张三北京A002U02250李四上海A003U0380NaNNaN注意 U03 这条订单在用户表里不存在所以用户姓名和城市填了 NaN。这就是我前面说的“匹配不上是常态”。这里用howleft的意思是以左表订单表为准左表所有行都必须保留右表能匹配上的就补进来匹配不上的置空。merge和concat的区别就在这concat是“物理拼接”不检查任何逻辑关系位置对上就拼merge是“逻辑配对”基于键值做关系运算。遇到“两表按某列关联”的需求永远选merge用concat强行拼只会制造数据错位。2.4 一张表理清三种方式的判断标准为了让选择更直观我把判断过程整理成一张表你的需求应该用的方式核心前提工具对应两张表结构相同要上下堆成一张表行拼接列名、列类型一致pandas concat(axis0)SQL UNION ALL两张表行数相同要左右并成一张表列拼接行顺序一一对应pandas concat(axis1)两张表通过某个键关联要按逻辑配对键连接键值类型一致明确连接方式pandas mergeSQL JOIN不确定怎么办先问自己行数应该不变、变多、还是变少判断结果是否符合业务预期直接测试观察结果形状这个表不是我拍脑袋写的而是无数次“搞错方向→返工→骂自己”之后沉淀出来的。合并之前先花十秒钟想清楚你期望合并后行数是多少。这个“预期行数”是你事后校验结果对不对的关键标尺没有这个标尺合并错了你都发现不了。3. 键连接背后的原理与常见误区3.1 从 SQL join 到 pandas merge同一个世界观键连接的思想在 SQL 里最直观。写过 SQL 的人都知道INNER JOIN、LEFT JOIN、RIGHT JOIN、FULL OUTER JOIN这几种连接方式。pandas 的merge把这些逻辑完整搬了过来只是参数名略有差异。对应关系如下SQL 连接方式pandas merge 的 how 参数结果语义INNER JOINhowinner只保留两表都能匹配上的行LEFT JOINhowleft保留左表全部行右表匹配不上的置空RIGHT JOINhowright保留右表全部行左表匹配不上的置空FULL OUTER JOINhowouter两表的行都保留匹配不上的置空我个人的经验是90% 的业务场景里left和inner就能覆盖绝大部分需求。left适合那种“以主表为准补充信息”的场景比如给订单表补用户信息inner适合那种“只关心两边都有的记录”的场景比如找出既注册过又下过单的用户。而outer和right用得少用的时候要格外谨慎因为行数变化往往超出预期。3.2 一对多与多对多行数为什么会“变多”很多新手第一次用 merge 时会被一个现象吓到明明左表只有 100 行合并完却变成了 300 行。这不是 bug而是你遇到了“一对多”或“多对多”连接。所谓一对多指的是连接键在左表中是唯一的但在右表里重复出现了。举个例子你把订单表和“订单折扣记录表”关联一个订单可能对应多笔折扣流水合并后这个订单的所有字段就会被复制成多行每行对应一条折扣记录。行数变多是完全正确的行为但如果你没意识到这一点后续做聚合统计时就会把订单金额重复计算得出一个虚高的数字。多对多连接更危险连接键在两张表中都重复。比如用“地区”做连接键一张表里有北京的 3 条记录另一张表里有北京的 4 条记录合并后北京就会出现 12 行也就是笛卡尔积。这种结果几乎很少是业务想要的但如果你不检查连接键的唯一性它就会悄悄发生。怎么防在 merge 之前检查连接键的唯一性方法很简单# 检查左表连接键是否有重复 assert df_left[key].is_unique, 左表连接键存在重复 # 如果有多对多的可能至少要在合并前知道 print(df_right[key].duplicated().sum())知道你的数据有多大基数的重复合并后行数会怎么变心里就有底了。合并完成后再对照你合并前预判的行数变化方向可以快速发现问题。3.3 常见误区以为合并是“横竖对齐”其实是“按钥匙配对”还有一个非常容易踩的误区就是把键连接和前面说的列拼接混为一谈。两者表面上看都是“把两张表左右放在一起”但底层的匹配逻辑完全不同。列拼接是“位置对齐”我说第 5 行和第 5 行是同一个东西强行放在一起键连接是“钥匙配对”我说用户 ID 是 U100 的订单要跟用户 ID 是 U100 的用户信息放在同一行不管这两行在各自表里排第几。什么时候容易出问题当两张表的行顺序恰好一样时用concat(axis1)确实能“蒙对”很多人就这么对付着写了。但只要上游数据顺序一变结果立刻错乱而且是那种不明显的错乱很难察觉。我见过一个案例某个数据管道跑了大半年某天上游加了一个字段导致文件输出顺序变化下游整体错位最后报表数字全部对不上排查了整整两天才定位到问题根源是“当年用了 concat 而不是 merge”。所以我的建议很绝对只要你的场景涉及“按键关联”不管两张表当前顺序怎么样一律用 merge不要图省事用 concat。代价只是多写一个参数换来的却是逻辑上的稳健。4. 大批量数据下的合并性能优化4.1 内存不够用的真实场景讲完原理和选择接下来是实操里最折磨人的部分数据量一大合并拼接就会撞到内存墙。我在一次处理用户行为数据时遇到过这个情况行为日志表有 3 亿行用户维度表有 500 万行。直接df_logs.merge(df_users, onuser_id, howleft)跑下去不到十秒就报了MemoryError。原因很简单日志表本身已经占了很大内存merge 过程中需要建立哈希索引、复制数据、产生临时结果峰值内存可能是最终结果的 2 到 3 倍。16GB 内存的笔记本根本扛不住。这不是个别案例。很多人在本地跑数据好好的一上生产数据量暴增就翻车本质上是没搞清楚合并操作的资源消耗规律。4.2 减少数据量先过滤、后合并、再补维度面对大表合并第一原则是能先过滤就先过滤能先聚合就先聚合不要急着 join 大表。举个例子你要分析最近 7 天的订单那就先把订单表筛选出最近 7 天的数据再去做合并。别把一年的大表和用户表 join 完之后再筛选那是把计算资源白白浪费在不需要的数据上。更进一步如果你的目标只是“给订单分组汇总后附带用户地区”你可以先在订单表上做 group by把几亿行聚合成几万行再和用户表 join。这是一个非常重要的优化思维合并操作最好发生在数据量尽可能小的阶段而不是一开始就去硬刚大表。4.3 内存不够时的底层策略用底层库或走 SQL 引擎如果过滤和聚合之后两张表还是很大怎么办这时要考虑换工具或者换实现方式。pandas 在 merge 时确实很方便但它的内存利用率不算高。社区里一个常见方案是把数据搬到 SQLite 或者 DuckDB 里做 join。DuckDB 做这个事尤其顺手一个嵌入式数据库引擎支持 SQL 语法能直接读 pandas DataFrame合完再转回来import duckdb # 直接对两个 DataFrame 执行 SQL join df_result duckdb.query( SELECT * FROM df_orders AS o LEFT JOIN df_users AS u ON o.user_id u.user_id ).df()实测下来数据量在千万行级别时DuckDB 的内存管理和执行效率明显优于 pandas 原生 merge而且写法就是 SQL对人类非常友好。如果你的数据到了亿行级别就乖乖上 Spark 或者 ClickHouse 这类分布式/列式系统在本地硬扛没有意义。4.4 提前排序与索引设计让“配对”变成“顺序扫描”还有一个容易被忽略的优化点理解不同连接算法的差异。pandas 和大多数数据库在做 join 时默认用的是哈希连接hash join把右表的连接键构建成哈希表然后遍历左表每一行去哈希表里查找。这种方式时间复杂度是 O(n)但构建哈希表需要额外内存。如果两张表都已经按连接键排好序那可以走合并连接merge join或者叫排序合并连接只需要按顺序同时扫描两张表内存占用小得多但前提是数据必须预先排序。在 pandas 里如果两张表都做了sort_values(key)有时候你可以更高效地配合一些底层操作。不过坦白说pandas 内部不一定总能自动切换到最优算法所以在实际操作中我更大的心得是不要在小数据上过度优化但大数据上一定要分段操作。把一个大 merge 拆成按月份、按区域分批合并每批结果落盘最后再合并结果。这个“分而治之”的思路比任何底层算法调优都来得可靠、可控。5. 实操中的踩坑清单附排查方法5.1 索引混乱导致的结果错位这是行拼接最常见的坑。前面提过ignore_indexTrue这里再展开讲一个具体的排查案例。某次我处理多个分片文件每个文件读进来都有一个 0 到 n 的索引我用concat直接拼起来忘了设置ignore_index。结果后续做df[df[order_id] A001]操作时还能正常工作但一旦有人用位置索引df.iloc[0]或者df.loc[0]就会得到两行甚至多行。更隐蔽的是如果把拼接结果再和别的表做 mergepandas 会警告“索引重叠”但有时只是 warning不报错一不留神就带病运行了。排查方法合并后立刻检查索引assert not df.index.duplicated().any(), 索引存在重复别嫌这句话多余一个ignore_indexTrue的设置加上一个断言就能把这个大坑填平。5.2 列名冲突suffixes 控制输出列名用 merge 时如果两个表都有“amount”这种通用列名pandas 不会报错而是自动给它们加后缀_x和_y。结果就是我经常看到有人代码里出现df[amount_x]、df[amount_y]时间一长自己也分不清哪个是哪个。推荐的做法是merge 之前先把重复列改好名或者显式用suffixes参数控制df_orders.merge(df_users, onuser_id, suffixes(_订单, _用户))这个后缀会让输出的列名变成amount_订单和amount_用户虽然有点长但可读性好了不止一个档次。对于有洁癖的数据管道来说这一步不是锦上添花是必需品。谁也不想半年后回来看代码时对着_x和_y发呆。5.3 键类型不一致字符串和整数的“婚配失败”这是另一个超级隐蔽的坑。假设订单表的user_id是字符串类型10001用户表的user_id是整数类型10001。逻辑上它们是同一个 ID但数据类型不同。merge 时 pandas 不会把字符串 10001 自动转成整数 10001结果就是所有行都匹配不上合并结果全是 NaN。排查方法在 merge 之前检查连接键的 dtype 并做统一df_orders[user_id] df_orders[user_id].astype(str) df_users[user_id] df_users[user_id].astype(str)这步看起来不起眼但它是我在真实项目中踩过最无语的坑之一。当年一份报表数据全是 NaN我花了一个小时才意识到是数据类型不一致而不是数据本身缺失。5.4 误用 outer join 导致行数膨胀外连接howouter在很多新手眼里是“保险选项”——“反正两边数据都保留应该没错”。但实际使用中外连接经常带来行数膨胀的问题。有一次我需要把两张都有脏数据的表关联起来心想用 outer 能保留所有数据结果两张表各有一些连接键在对方表中不存在合并后行数等于“完全匹配行 左表独有行 右表独有行”比左表多了 30%。下游再做聚合统计时关联不上的记录全部参与统计导致数字虚高业务方当场质疑数据质量。我的建议默认优先用left连接因为大多数业务的语义是“主表驱动、补充字段”。只有你明确知道业务上需要保留右表独有的数据时才考虑outer。连接方式不是越“全”越好而是越贴近业务语义越好。5.5 文件读取阶段的编码与分隔符问题最后补一个容易在“合并前”就踩的暗坑多个数据文件本来应该用相同编码规则保存但上游某个人用 Excel 另存了一次导致文件变成了 GBK 或 UTF-8 with BOM。读进来之后列名出现乱码、字符串列前面带一个\ufeff前缀合并时键匹配全部失败。解决办法是读取文件时显式指定编码df pd.read_csv(users.csv, encodingutf-8-sig)如果你不确定文件编码就直接用工具先探测一下。多数情况下utf-8-sig这种编码既能处理带 BOM 的文件也能处理普通 UTF-8。反正读文件时多写一个encoding参数不会让你损失什么不写却可能让你在合并阶段排查半天。6. 从开篇到后续合并拼接之后还有哪些关卡合并与拼接之所以值得开篇是因为它是数据整理阶段的地基。地基没打牢后面做筛选、统计、分组、可视化全都是空中楼阁。这一篇我们把三种合并方式的选择逻辑、merge 背后的连接原理、大数据量下的性能优化思路、以及实际操作中的高频坑都过了一遍。核心要点我再浓缩一下合并前先想清楚是“上下堆”“左右拼”还是“按键配对”三者的工具和前提完全不同键连接要时刻关注行数的变化逻辑一对多、多对多不可怕可怕的是你没预期到大批量场景下先过滤、先聚合、再合并必要时换 DuckDB、SQL 引擎别在 pandas 里硬扛索引、列名冲突、键类型不一致、编码问题每一个都值得在代码里加断言或显式参数去防。这个系列既然叫“开篇”后面自然会继续展开数据处理的其他关键关卡分组聚合的细节与陷阱、数据清洗中缺失值和重复值的处理策略、筛选与统计的组合拳、以及如何把这些操作串联成一个完整的数据流水线。如果你在实践合并与拼接时遇到了这篇里没覆盖到的奇怪问题或者对某个细节有不同看法欢迎交流。这种基础操作恰恰是最值得反复打磨的地方很多时候一个细节处理得好不好决定了你是花两小时写完脚本还是花两天在查 bug。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。