搞数据分析的兄弟,pandas 用久了,merge、join、concat 这三兄弟的分工你搞明白了吗?坦白说,我见过不少入门一两年的朋友,仍然在这三个方法之间来回试。逻辑上用对了,数据量小的时候无感,顶多多跑几秒;可用错了方向,结果对不上、索引乱掉、内存暴涨,这种坑我踩了不止一回。
这三个方法表面上都是“把数据放在一起”,但各自的底层逻辑、对齐方式、适用场景差别很大。这篇文章我用一套订单数据和客户数据,结合真实业务场景,把 merge、join、concat 彻底拆开讲透。不需要复杂数据集,一个 DataFrame 构造完就能跟着跑。看完之后,你会很清楚什么场景该选哪个方法,以及为什么。
1. 先说结论:拼接和连接,两者根本不是一回事
先记住一个关键判断:concat 是拼接,merge 和 join 是连接。这个区分是整个 pandas 数据处理中最容易忽视、也最影响结果正确性的概念之一。
拼接(concat)就是把两份或多份数据按顺序堆在一起,像两列积木拼成一条长龙。它不关心两边有没有共同的键,也不做任何匹配,只做“物理堆叠”。它解决的典型问题是:今天你导出了三个 CSV 片段,结构完全一样,想合成一张完整表。
连接(merge / join)则完全不同。它像查户口一样,拿着一个或多个键,到另一张表里找对应的行,然后按匹配结果拼出来。它本质上是关系型数据库里 JOIN 的翻版,解决的是“我手里有订单表,客户信息在另一张表,怎么把客户城市拉到订单明细里”这类问题。
下面这张表是我常用的对比速查表,已经按高频使用场景做了精简:
| 方法 | 本质 | 对齐依据 | 类似 SQL 语义 | 默认连接方式 | 适用场景 |
|---|---|---|---|---|---|
| concat | 沿轴堆叠 | 无键匹配,按行/列位置堆叠 | UNION ALL | 行方向 outer(列取并集) | 多份同构数据合并、追加行、追加列 |
| merge | 列连接 | 指定列(on / left_on / right_on) | JOIN | inner join | 表与表之间按业务键关联 |
| join | 索引/列连接 | 默认索引,也可指定列 | JOIN(基于索引) | left join | 索引对齐、按索引合并、少量列合并 |
注意最后一列,merge 默认 inner join,join 默认 left join。这个差异如果你没意识到,后面写出来的结果很可能就是“好像少了行,又好像没少”,排查半天发现是默认行为在作怪。
2. merge 实战:基于列的连接,pandas 里的 SQL JOIN
2.1 merge 的核心参数逐个拆
merge 的签名其实非常长,记不住没关系,你只要抓住几个高频参数就够用:
pd.merge( left, right, how='inner', # inner / outer / left / right on=None, # 两边同名的连接键 left_on=None, # 左边表的连接键列名 right_on=None, # 右边表的连接键列名 left_index=False, # 是否用左边索引作为连接键 right_index=False, # 是否用右边索引作为连接键 suffixes=('_x', '_y'), # 同名列冲突时自动加后缀 indicator=False # 是否加一列记录来源 )普通业务场景里,on、how、left_on/right_on、suffixes四个参数已经覆盖九成需求。
来一套可以直接复制的数据,后面所有示例都用这套数据展开:
import pandas as pd orders = pd.DataFrame({ "订单号": ["A001", "A002", "A003", "A004", "A005"], "客户ID": ["C1", "C2", "C3", "C2", "C5"], "商品ID": ["P1", "P2", "P1", "P3", "P2"], "金额": [299, 89, 458, 129, 699] }) customers = pd.DataFrame({ "客户ID": ["C1", "C2", "C3", "C4"], "姓名": ["张三", "李四", "王五", "赵六"], "城市": ["北京", "上海", "广州", "深圳"] })这里订单表有 5 条记录,客户表有 4 个客户。注意订单表里有一个“C5”在客户表里并不存在,这就是后续排查 left / inner 区别的关键素材。
2.2 四种 how 模式一次看明白
先做最常用的 inner(内连接):
res = pd.merge(orders, customers, on="客户ID", how="inner") print(res.shape)结果:订单表里有 C1、C2、C3,客户表里都有,所以 4 条订单能匹配上;A005 订单对应的 C5 在客户表没有,直接丢掉。inner 只保留两边都匹配得上的行,这是它和 SQL inner join 一致的地方。
再看 left(左连接),我日常用得最多的一种:
res = pd.merge(orders, customers, on="客户ID", how="left")结果保留了订单表全部 5 行,C5 那行客户表的“姓名”“城市”填充为 NaN。这里有个重要理念:业务上“订单是主表”,客户信息是“补全信息”,订单一条都不能少,所以必须用 left。很多数据分析新手把订单关联客户写成 inner,导致订单行数变少,后面对账怎么都对不上,就是这一步出的问题。
right 和 outer 同理,右连接保留右表所有行,全外连接保留两边所有行,没有匹配的字段用 NaN 填充。搞清楚“谁保留全部行”就抓住了 how 的本质。
2.3 连接键列名不一致怎么办
现实数据永远没有教程里那么干净。订单表里叫“客户ID”,客户表里叫“客户编号”,这时候用left_on和right_on:
customers2 = customers.rename(columns={"客户ID": "客户编号"}) pd.merge(orders, customers2, left_on="客户ID", right_on="客户编号", how="left")结果里会出现两列:客户ID 和客户编号。客户编号列其实就是给匹配用的副本,分析时通常用.drop(columns='客户编号')把它清理掉,或者 merge 后用.loc挑选需要的列。这里还有个快捷键,当两边键都对应索引时,可以直接开启left_index=True和right_index=True,绕开列名不一致的问题,但这种场景更建议用下一节的 join。
2.4 一对多和多对多:不要被结果“变多”吓到
merge 还有一个让很多人困惑的行为:如果两边的键都出现多次,结果行数会膨胀。举个例子,订单表里 C2 下了两笔单,客户表里 C2 只有一条,关联后 C2 的客户信息会复制到两行,这就是一对多。
# 客户表追加一条重复客户ID,模拟多对多 dup_customers = pd.DataFrame({ "客户ID": ["C1", "C1", "C2"], "姓名": ["张三", "张小三", "李四"], "城市": ["北京", "北京", "上海"] }) pd.merge(orders, dup_customers, on="客户ID", how="left")因为这个 dup_customers 表里 C1 出现了两次,而订单表里 C1 只有一笔,所以那笔订单会生成两行,这就是多对多导致的笛卡尔积式膨胀。merge 本身不帮你识别“这个键是否唯一”,你必须在业务上确认:连接的结果预期是一对一还是一对多?如果列不唯一且不希望膨胀,先聚合再去 merge,或者明确使用validate参数:
pd.merge(orders, dup_customers, on="客户ID", how="left", validate="one_to_many")validate会自动检查连接关系是否符合预期,不符合时直接抛异常。我之前在一次销售明细汇总里就是因为没有加这个校验,结果行数莫名翻倍,排查了大半天。现在数据量大的 join 操作我都会加上。
3. join 实战:默认走索引的左连接
3.1 join 的通用语法
join 与 merge 最大的区别在于默认连接键是索引(index)。语法上它是 DataFrame 的实例方法:
orders.join(other=customers, on="客户ID", how="left")或者更常见的写法,先把客户表按索引设置好:
customers.set_index("客户ID", inplace=True) orders.join(customers, on="客户ID", how="left")注意这里 orders 自身的索引还是 0 到 4,没有变化,join 通过on参数指定“用 orders 的客户ID 列去匹配 customers 的索引”。如果不写on,比如默认情况orders.join(customers),那 pandas 会尝试用 orders 的索引和 customers 的索引对齐,两边恰好都是 RangeIndex 时,结果就是按位置硬对齐,这在业务场景下几乎没有意义。
3.2 为什么 join 默认是 left join
这是所有教程里最容易被跳过、但实际最有价值的一点。merge 默认是 inner,因为 SQL 的 JOIN 默认就是 inner;而 join 是 DataFrame 自己的方法,面向的对象往往是一个“主表”,顺序上通常就是“我要在这个表上补信息”,所以 pandas 把默认设成 left join,避免主表行数减少。
我用 join 最多的时候是三张以上表要按同一个索引拼信息。比如按客户 ID 索引,把客户详情、会员等级、消费积分三张表一次性拼到订单表上:
orders.join( [customers.set_index("客户ID"), membership.set_index("客户ID"), points.set_index("客户ID")], on="客户ID", how="left" )这个写法比连续 merge 三次干净太多。join 可以传一个列表,一次合并多张表。如果需要合并的表都共享同一个索引,连on都不用写:
customers.set_index("客户ID", inplace=True) membership.set_index("客户ID", inplace=True) orders.set_index("客户ID", inplace=True) orders.join([customers, membership], how="left")这样三个表都按客户ID作索引,直接一拼到位。注意这种做法会把 orders 原来的“订单号”等列保留,索引变成客户ID,如果需要还原,最后reset_index()即可。
3.3 join 和 merge 的关系:不是“谁替代谁”
实际上 join 在 pandas 内部实现上是基于 merge 的封装,但有几个行为差异:
- join 的默认连接键是索引,merge 的默认连接键是同名列。
- join 可以一次合并多个 DataFrame,merge 一次只能合并两个。
- join 默认是 left,merge 默认是 inner。
所以如果你对着一张“键在索引上”的表做关联,join 的代码更短;如果你主要靠业务列关联,且需要精细控制连接键名称,merge 更灵活。选谁不是看谁更高级,而是看你的数据结构和可读性要求。
4. concat 实战:沿轴的堆叠,一次把多份数据合一起
4.1 axis=0 和 axis=1 到底怎么理解
concat 的核心参数是axis。axis=0表示沿着行的方向拼接,也就是上下堆叠,要求两张表的列结构一致;axis=1表示沿着列的方向拼接,也就是左右拼宽,要求两张表的行一一对应。
这句“一一对应”太重要了,很多人把 axis=1 当成 merge 来用,但 concat 根本不检查行与行之间是否匹配,它只是把左边 DataFrame 和右边 DataFrame 相同位置的 row 硬拼在一起。如果你的两张表行顺序一致,比如分别是同一个批次的订单金额和退款金额,那 axis=1 没问题;如果行顺序完全不一致,哪怕索引一致,它也会按位置硬拼接,结果直接错乱。
来看一段代码:
orders_jan = pd.DataFrame({ "订单号": ["J001", "J002"], "金额": [199, 599] }) orders_feb = pd.DataFrame({ "订单号": ["F001", "F002", "F003"], "金额": [299, 399, 99] }) # 行方向堆叠 pd.concat([orders_jan, orders_feb], axis=0, ignore_index=True)输出 5 行,就是简单地接在后面。因为 orders_feb 的订单号以 F 开头,根本不存在键匹配这种概念。这才是 concat 的正确使用场景:同构表量多份,想拼成一份。
4.2 join 参数以及 ignore_index 的坑
concat 的join参数控制“轴之外的另一个方向”如何处理。默认是outer,也就是保留两边所有列,缺失的列用 NaN 填充:
orders_extra = pd.DataFrame({ "订单号": ["F004"], "金额": [49], "备注": ["退货暂缓"] }) pd.concat([orders_jan, orders_extra], axis=0)注意,orders_extra 多了一个“备注”列,默认 concat 会把 备注 列保留,前面几行都填 NaN。这种“自动补列”在很多时候很省事,但它隐藏了一个风险:如果两边列名拼写稍有不同,比如一边是“金额 ”,一边是“金额”(全角和半角空格),concat 不会报错,而是悄悄给你生成两列,数据直接分家。所以我强烈建议 concat 之前统一检查列名。
ignore_index=True则更常见。concat 默认会保留每个 DataFrame 原来的索引,下标会重复。比如 orders_jan 的索引是 0、1,orders_feb 的索引也是 0、1、2,拼接后就会出现两个 0、1,还有 2。为后续筛选、分组带来麻烦,通常拼接后要reset_index(drop=True),或者直接 concat 时传ignore_index=True一步到位。
4.3 keys 参数:分组拼接两不误
concat 里还藏着一个好用的参数keys。它可以在拼接同时给每份数据打上标签,最终生成多层索引:
merged_all = pd.concat( [orders_jan, orders_feb], ignore_index=False, keys=["1月", "2月"] ) merged_all结果索引会变成多级索引,第一级是“1月”、“2月”,第二级是原来的索引。这样后续merged_all.loc["1月"]就能直接取出 1 月的数据,非常利于这种按月份追加的场景。
4.4 concat 与 merge 的本质差异再强调
上一节讲过,concat 是做堆叠,merge 是做匹配。我举一个典型错误案例:有一个“订单主表”和一个“退款表”,你想把退款金额填到订单上。
如果直接用 concat axis=1,订单主表 1000 行,退款表只有 120 行,结果会怎么拼?它会按位置拼,第 121 行往后退款金额全是 NaN,而且退款表没有的订单反而对齐到了错误的位置。正确做法是用 merge 按订单号关联,或者把退款表按订单号索引后用 join。
一个简单的记忆口诀:如果列方向是对齐拼接,用 concat;如果是按业务键把一张表的值填到另一张表,用 merge 或 join。
5. 场景选择指南:遇到真实业务这么选
5.1 高频场景速查
我在表格里花了点心思,把平时见得最多的几类需求直接对应到了方法:
| 业务需求 | 推荐方法 | 原因 |
|---|---|---|
| 多份同结构 CSV 合成一张表 | concat(axis=0) + ignore_index | 结构一致,纯堆叠 |
| 按订单号关联客户信息 | merge(on='订单号', how='left') | 按业务键匹配,保留主表 |
| 按客户ID索引关联多张维表 | join(how='left') | 多表连续合并,代码简洁 |
| 同一批订单,宽表两侧字段补齐 | concat(axis=1) | 行顺序一致,纯横向展开 |
| 订单表和退款表按订单号配对 | merge(on='订单号', how='outer') | 需要键匹配,可能出现一对多 |
| 调查数据分组汇总后合并展示 | concat(keys=['组1','组2']) | 需要分组标签,后续按组切片 |
5.2 一个综合案例:订单、客户、退款三表关联
用一个完整案例收拢前面的知识。假设手头有订单表、客户表、退款表三份数据,目标是输出一份包含订单明细、客户城市、退款金额的宽表。
orders = pd.DataFrame({ "订单号": ["A001", "A002", "A003", "A004"], "客户ID": ["C1", "C2", "C3", "C2"], "金额": [299, 89, 458, 129] }) customers = pd.DataFrame({ "客户ID": ["C1", "C2", "C3", "C4"], "姓名": ["张三", "李四", "王五", "赵六"], "城市": ["北京", "上海", "广州", "深圳"] }) refunds = pd.DataFrame({ "订单号": ["A002", "A004"], "退款金额": [89, 129] }) # 第一步:订单关联客户,保留全部订单 step1 = pd.merge(orders, customers, on="客户ID", how="left") # 第二步:订单关联退款,退款存在时才配对 final = pd.merge(step1, refunds, on="订单号", how="left") final这里第二步不能替换成 concat,因为退款表只覆盖部分订单,而且按订单号匹配,concat 会把退款表整体“接下面”而不是“填进来”。用 left join 能干净地保留全部订单,没退款的订单退款金额为 NaN。如果业务要“只展示有退款的订单”,再把第二步改成 inner join 即可。
5.3 性能参考与取舍
数据量大了以后,性能也是一个维度。我的实测经验是:底层逻辑上 merge 是 C 实现的 hash join,比纯 Python 循环快至少两个数量级;join 因为内部也走 merge,性能接近。concat 则更轻量一些,因为它不涉及键匹配,只做内存块的复制和拼接。
有一个实际优化技巧:如果两张表都是千万级,merge 之前最好先把参与连接但不参与结果的列 drop 掉,减少内存拷贝。另一个常见建议是在 merge 前确保连接键为合适的类型,比如把“客户ID”统一成字符串,否则两边一个 int 一个 object 会导致匹配失败,虽然结果会按 NaN 填而不是报错,但你已经踩坑了。
6. 常见问题与排查实录
6.1 合并后行数“变多”或“变少”,先确认主键唯一性
这是咨询频率最高的坑。inner 连接行数变少是预期行为;left 连接行数变多,大概率右表连接键重复,每个匹配都复制了一次。排查方法很简单:在合并之前对右表连接键做value_counts(),看是否有重复值。
customers["客户ID"].value_counts()如果重复存在,考虑先drop_duplicates(subset="客户ID"),再去 merge。业务上如果右表同一键对应多条信息,还要想清楚保留哪条,比如按日期取最新。
6.2 同名列自动加后缀,偶尔会藏脏数据
merge 时两边有同名列但不作为连接键,比如订单表里有“备注”,退款表里也有“备注”,结果会出现“备注_x”和“备注_y”。很多人会忘记清理这两列,后面处理时用的是“备注_x”,却以为自己在看合并后的“备注”。我的习惯是 merge 之后立刻print(result.columns.tolist())检查常用的列名,确认没有意外后缀,再继续分析。
如果你对后缀有明确偏好,可以自定义:
pd.merge(orders, refunds, on="订单号", how="left", suffixes=("_订单", "_退款"))这样结果里会出现“备注_订单”、“备注_退款”,比默认的“_x”、“_y”可读性好很多。
6.3 concat 后行数和索引都好,但 DataFrame 还是“翘了”
有时候 concat 之后看着数据没问题,但用于透视或分组时,很多操作突然报错或者结果奇怪。这通常是因为各分片 DataFrame 的列顺序不一致,concat 按列名对齐可能导致列顺序重排;或者有的分片有空 DataFrame、列全部 NaN,导致 dtype 变成了 object。解决方法是明确sort=False固定列顺序,并在 concat 后用.info()检查 dtype 是否异常。
如果每批次数据的列数量一致但列名顺序不同,建议 concat 前统一.reindex(columns=标准列顺序),保证对齐逻辑稳定。
6.4 索引唯一性被破坏,join 结果出现意外笛卡尔积
join 按索引连接,如果右表索引有重复,结果会和 merge 的多对多行为一样膨胀。这个问题特别隐蔽,因为 set_index 之后,你不会一眼看出索引是否有重复。我会在 join 前加一个断言级别判断:
assert not customers.index.duplicated().any(), "索引存在重复,请先清理"至少三张表一起 join 时,这个 assert 能救我一命。
6.5 嵌套列和 MultiIndex 的细节问题
如果 DataFrame 本身带着多层索引或列名是 MultiIndex,concat 和 merge 的行为都会变得更复杂。比如 concat 多份 MultiIndex 列的数据,默认内层列名不一致会用 NaN 补齐。一个可行的兜底方案是把所有 DataFrame 统一做reset_index()、拍平列名,再走常规的 merge 或 concat。虽然放弃了一部分 MultiIndex 的高级特性,但代码鲁棒性高很多,特别是数据来源不稳定时。
7. 一点个人实操心得
用这三个方法这几年,我最大的体会是:不要靠“试”来选方法,要靠在动手前先问自己两个问题。第一个问题是“数据之间有没有键需要匹配?”有,就是 merge 或 join;没有,就是 concat。第二个问题是“我的键在索引上、还是在一列里?”在索引上,优先 join;在列里,优先 merge。
另外一个小习惯可能对你有用:每次做表关联之前,先记录左表的行数,关联之后再记录一次。如果行数变了,你第一时间就能知道连接关系不是一对一,这一步检查只需两秒,但能挡住大多数合并带来的脏数据事故。
最后,代码里把how参数写明确,不要依赖默认值。哪怕你要的刚好是 inner,也写上how="inner",三个月后回来看代码,不会再去猜当时的意图。同样的道理,concat 里明确写axis=0、join='outer'。代码可读性和结果正确性,有时候是一回事。