1. 为什么值得花时间重新梳理pandas的拼接与合并
做数据挖掘这行,绕不开的一件事就是“把数据拼起来”。你从业务库导出一份订单表,从日志系统拉一份用户行为表,从第三方接口拿一份商品维度表,最后要做的第一件事往往不是建模,而是把这些散落各处的表整合成一张能用的宽表。这个过程里,pandas的concat、merge、join就是每天都要打交道的工具。
我见过太多人在这几个函数上翻车:明明两个表看着能对上,合并完行数暴涨;明明索引都是唯一的,拼接完却多出一堆重复列;明明只是想把三月份和四月份的数据摞在一起,结果列名对不上,凭空多出十几列空值。这些问题不复杂,但排查起来很烦,因为它们不会报错,只会悄悄给你一个错误的结果,等你发现的时候模型已经跑完了。
这篇内容适合两类人看:一类是刚学完pandas基础、正准备上手真实数据的新手,另一类是用了一段时间但总觉得“合并这块心里没底”的从业者。我会把concat和merge这两条主线拆开讲清楚,重点放在参数背后的逻辑和实际踩过的坑上,而不是简单罗列API文档。看完之后,你至少能做到:拿到两张表,知道该用哪个函数、该设哪些参数、合并完怎么验证结果对不对。
2. 拼接与合并的整体设计思路
2.1 先搞清楚“拼接”和“合并”到底在解决什么问题
很多人把concat和merge混着用,觉得反正都是把两个表弄到一起。但它们的适用场景完全不同,选错了函数,后面参数怎么调都别扭。
concat解决的是同构数据的堆叠问题。什么叫同构?就是两张表的列结构基本一致,只是记录不同。比如一月份的销售明细和二月份的销售明细,列都是“订单号、商品、金额、日期”,你要做的是把它们上下摞起来,变成一张覆盖两个月的大表。这种操作在数据库里叫UNION ALL,在pandas里就是concat。
merge解决的是异构数据的关联问题。两张表的列不一样,但通过某个共同的字段能建立起对应关系。比如订单表里有“用户ID”,用户表里有“用户ID、年龄、城市”,你想把年龄和城市贴到订单表上,这就是merge。数据库里叫JOIN,pandas里就是merge。
判断标准很简单:如果两张表的列名几乎一样,你想让行数变多,用
concat;如果两张表的列名不同,你想让列数变多,用merge。
这个判断逻辑听起来简单,但实际工作中我见过有人用merge去堆叠同构数据,结果因为没有共同的关联键,只能拿索引硬凑,最后搞出一堆_x、_y后缀的重复列,自己都看不懂。
2.2 为什么pandas要设计两套API
这背后其实是数据操作的两个基本维度:纵向扩展和横向扩展。
纵向扩展对应concat,它的核心参数是axis。axis=0表示沿着行的方向拼接,也就是上下摞;axis=1表示沿着列的方向拼接,也就是左右并。日常用得最多的是axis=0,axis=1一般用在“两张表的索引能对齐,想把它们并成一张宽表”的场景,但这个场景其实用merge更安全,因为merge会显式检查关联键,而axis=1只是按索引位置硬拼,索引对不上就会产生大量空值。
横向扩展对应merge,它的核心是how参数,控制关联方式。inner取交集,left保留左表全部,right保留右表全部,outer取并集。这四个选项对应了SQL里的四种JOIN,理解起来不困难,难的是关联键有重复值时会发生什么。
我个人的经验是:concat的风险在于列名和索引的对齐,merge的风险在于关联键的重复。把这两个风险点记住,大部分问题都能提前规避。
2.3 方案选型的三个实际考量
在实际项目里,选concat还是merge,除了看数据结构,还要考虑三个因素。
第一是数据量。concat是简单的行追加,内存开销相对可控;merge涉及键的匹配,如果两张表都很大,内存会飙升。我处理过一张两千万行的订单表和一张五百万行的用户表做merge,中间过程占用的内存是原始数据的四五倍。这种情况下,要么先对用户表去重,要么分批合并。
第二是索引的处理。concat默认保留原索引,拼接后会出现索引重复。如果你后续要用loc按索引取值,重复索引会让你拿到多行结果。所以我在拼接后通常会加一句reset_index(drop=True),把索引重置成连续整数。
第三是列名的冲突。merge时如果两张表有同名列但不是关联键,pandas会自动加_x和_y后缀。这个默认行为有时候方便,有时候添乱。我习惯用suffixes参数显式指定后缀,比如suffixes=('_订单', '_用户'),这样合并完一眼就能看出哪列来自哪张表。
3. concat拼接的核心细节与实操要点
3.1 纵向拼接:最常见的场景和最容易被忽略的参数
纵向拼接的代码写起来很简单:
import pandas as pd df1 = pd.DataFrame({ '订单号': ['A001', 'A002'], '金额': [100, 200] }) df2 = pd.DataFrame({ '订单号': ['A003', 'A004'], '金额': [300, 400] }) result = pd.concat([df1, df2], axis=0, ignore_index=True)这里我加了ignore_index=True,这是实际工作中几乎必加的参数。不加的话,结果的索引是0, 1, 0, 1,重复索引会给后续操作埋雷。加了之后,索引变成0, 1, 2, 3,干净利落。
但ignore_index只是第一步。真正容易出问题的是列名不一致的情况。假设df1有“订单号、金额”两列,df2有“订单号、金额、折扣”三列,拼接后结果会有“订单号、金额、折扣”三列,df1那部分的“折扣”列全是NaN。这个行为本身是合理的,但如果你没注意到,后续做聚合计算时NaN会干扰结果。
我的做法是:拼接前先检查两张表的列名是否一致。
print(set(df1.columns) == set(df2.columns))如果返回False,就要决定是补列还是删列。补列用reindex,删列用drop。这个检查花不了几秒钟,但能省掉后面半小时的排查时间。
3.2 横向拼接:什么时候用,什么时候千万别用
axis=1的横向拼接,本质上是按索引把两张表并在一起。它要求两张表的索引能对上,否则对不上的位置会填NaN。
df_left = pd.DataFrame({'A': [1, 2, 3]}, index=[0, 1, 2]) df_right = pd.DataFrame({'B': [4, 5, 6]}, index=[0, 1, 2]) result = pd.concat([df_left, df_right], axis=1)这个例子能正常工作,因为索引完全一致。但如果df_right的索引是[1, 2, 3],结果就会变成四行,索引0那行的B列是NaN,索引3那行的A列是NaN。
我个人的建议是:除非你非常确定两张表的索引一一对应,否则不要用axis=1。想横向扩展列,用merge更安全,因为merge会显式指定关联键,不会依赖隐式的索引对齐。
3.3 用keys参数给拼接结果加来源标记
当你要拼接多张表,并且后续需要知道每行数据来自哪张表时,keys参数非常有用。
result = pd.concat([df1, df2], keys=['一月', '二月'])这样结果的索引会变成多层索引,第一层是“一月”或“二月”,第二层是原索引。后续可以用result.loc['一月']快速取出某个月的数据。
这个技巧在拼接多个来源的数据时特别实用。比如你把三个不同渠道的用户数据拼在一起,用keys=['渠道A', '渠道B', '渠道C']标记来源,后续做分组分析时直接按第一层索引分组就行,不用额外加一列来源字段。
注意:用了
keys之后,ignore_index=True会失效,因为多层索引本身就是有意义的。如果你既想要来源标记又想要连续索引,可以先拼接再reset_index,然后用droplevel调整索引层级。
3.4 拼接时的数据类型陷阱
这是很多人踩过但很少被提及的坑:拼接会导致数据类型变化。
假设df1的“金额”列是整数,df2的“金额”列是浮点数,拼接后整列会变成浮点数。这本身没问题,但如果df2的“金额”列里混了一个字符串'未知',拼接后整列会变成object类型,后续做数值计算直接报错。
我的习惯是:拼接前用dtypes检查两张表的列类型。
print(df1.dtypes) print(df2.dtypes)如果发现类型不一致,先统一类型再拼接。数值列用astype转换,字符串列用fillna处理缺失值。这个步骤看起来繁琐,但比拼接后发现问题再回头改要高效得多。
4. merge合并的核心细节与实操要点
4.1 四种关联方式的实际含义和选择依据
merge的how参数有四个选项,我用一个具体例子说明它们的区别。
假设订单表有订单号A001, A002, A003,用户表有用户IDU001, U002,订单表里有个“用户ID”字段,值是U001, U002, U001。
how='inner':只保留两张表都能对上的记录。如果订单表里有个订单的用户ID是U003,用户表里没有,这条订单就被丢弃。how='left':保留左表全部记录,右表对不上的填NaN。订单表全部保留,用户表里没有U003的信息,对应位置就是空值。how='right':保留右表全部记录,左表对不上的填NaN。用户表全部保留,如果某个用户没有订单,订单相关列就是空值。how='outer':两张表的记录都保留,对不上的互相填NaN。
选择哪个,取决于你的分析目标。如果你要做用户画像,需要每个订单都有用户信息,用inner;如果你要统计所有订单的情况,哪怕有些订单的用户信息缺失也要算进去,用left。
我个人的默认选择是left,因为大多数分析场景下,主表(比如订单表)的记录不能丢,关联表的信息能补多少补多少。
4.2 关联键有重复值时会发生什么
这是merge最容易出问题的地方,也是面试里经常问的点。
假设左表是订单表,一个用户有多个订单,所以“用户ID”在左表里重复出现。右表是用户表,“用户ID”是唯一的。这种情况下merge没问题,左表的每个订单都能匹配到右表的用户信息。
但如果右表的“用户ID”也有重复呢?比如用户表里同一个用户有两条记录(可能是数据录入错误),那么左表的一条订单会匹配到右表的两条用户记录,结果就是行数膨胀。原本1000条订单,合并后变成1200条,多出来的200条是重复匹配产生的。
这个问题的排查方法是:合并前检查关联键的唯一性。
print(df_left['用户ID'].is_unique) print(df_right['用户ID'].is_unique)如果右表的关联键不唯一,要么先去重,要么用drop_duplicates保留一条。我通常会在合并前对右表做一次去重,确保关联键唯一,这样合并后的行数就等于左表的行数,结果可控。
4.3 用validate参数提前发现关联问题
pandas从0.21版本开始提供了一个validate参数,可以在合并时检查关联关系是否符合预期。
result = pd.merge(df_left, df_right, on='用户ID', how='left', validate='many_to_one')validate有四个选项:one_to_one、one_to_many、many_to_one、many_to_many。如果你预期左表的关联键可以重复、右表必须唯一,就用many_to_one。如果实际数据不符合这个关系,pandas会直接报错,而不是悄悄给你一个错误结果。
这个参数我强烈建议加上。它相当于一个断言,帮你把问题挡在合并之前,而不是合并之后才发现行数不对。
4.4 多键合并和索引合并
有时候关联键不止一个。比如订单表和用户表要通过“用户ID”和“地区”两个字段才能唯一确定一条记录,这时候on参数传一个列表:
result = pd.merge(df_left, df_right, on=['用户ID', '地区'], how='left')还有一种情况是关联键在索引里而不是列里。比如右表的索引是“用户ID”,左表的“用户ID”是普通列。这时候可以用left_on和right_index:
result = pd.merge(df_left, df_right, left_on='用户ID', right_index=True, how='left')这种写法在处理时间序列数据时很常见,因为时间序列的索引往往就是时间戳。但要注意,用索引合并后,结果的索引会变得复杂,我通常会在合并后reset_index把索引重置掉。
5. 实操过程与核心环节实现
5.1 一个完整的合并案例:从原始表到分析宽表
假设我手上有三张表:订单表、用户表、商品表。订单表有“订单号、用户ID、商品ID、金额、日期”,用户表有“用户ID、年龄、城市”,商品表有“商品ID、品类、成本”。
目标是生成一张宽表,包含订单号、金额、日期、用户年龄、用户城市、商品品类、商品成本。
第一步,先检查三张表的关联键唯一性。
print(orders['用户ID'].is_unique) # False,一个用户多个订单 print(users['用户ID'].is_unique) # True print(products['商品ID'].is_unique) # True第二步,先把订单表和用户表合并。
merged = pd.merge( orders, users, on='用户ID', how='left', validate='many_to_one', suffixes=('', '_用户') )这里suffixes设成('', '_用户'),意思是左表列名不变,右表如果有同名列就加“_用户”后缀。因为订单表和用户表没有同名列,这个设置其实用不上,但养成习惯没坏处。
第三步,再把结果和商品表合并。
final = pd.merge( merged, products, on='商品ID', how='left', validate='many_to_one', suffixes=('', '_商品') )第四步,验证结果行数。
print(len(orders), len(final))如果两个数字相等,说明合并没有产生行数膨胀。如果final比orders多,说明某一步的关联键有重复,需要回头检查。
第五步,检查空值情况。
print(final.isnull().sum())如果“年龄”或“城市”列有大量空值,说明有些订单的用户ID在用户表里找不到,需要确认是数据缺失还是关联键格式不一致(比如一个是字符串一个是整数)。
5.2 参数选择的计算过程
在合并大表时,how的选择会直接影响结果集大小。假设订单表有100万行,用户表有50万行,其中订单表里的用户ID覆盖了用户表里80%的用户。
- 用
inner:结果行数约等于订单表中能匹配到用户的记录数。如果订单表里有10%的用户ID在用户表里不存在,结果就是90万行。 - 用
left:结果行数等于订单表行数,100万行。对不上的10%用户信息填NaN。 - 用
outer:结果行数等于订单表行数加上用户表里没有被任何订单关联的用户数。如果用户表里有5万用户没有订单,结果就是105万行。
这个计算过程在合并前就能预估,帮你判断结果是否合理。如果实际结果和预估差距很大,说明关联键有问题。
5.3 合并后的列名整理
合并完成后,列名往往带有后缀或者顺序混乱。我通常会做两件事:重命名和重排序。
final = final.rename(columns={ '金额': '订单金额', '成本': '商品成本' }) final = final[['订单号', '日期', '订单金额', '用户ID', '年龄', '城市', '商品ID', '品类', '商品成本']]重命名是为了让列名更有业务含义,重排序是为了让宽表的列按逻辑分组。这个步骤不影响计算,但影响后续的可读性和维护性。我见过有人合并完直接扔给下游,结果下游同事花了一上午才搞清楚哪列是哪列。
6. 常见问题与排查技巧实录
6.1 合并后行数暴涨的排查思路
行数暴涨是merge最典型的问题,原因几乎都是关联键重复。排查步骤:
- 检查右表关联键是否唯一。用
df['键'].is_unique,返回False就说明有重复。 - 找出重复的键值。用
df[df['键'].duplicated(keep=False)]查看具体哪些行重复。 - 决定处理方式。如果是数据错误,去重;如果是业务上确实存在一对多关系,改用
how='left'并接受行数增加,但要在合并后做聚合。
我遇到过一个案例:用户表里同一个用户有两条记录,一条是注册信息,一条是实名认证信息。合并时没注意,订单表直接膨胀了一倍。后来改成先按用户ID聚合用户表,取最新的一条记录,再合并,问题解决。
6.2 关联键格式不一致导致匹配失败
这个问题很隐蔽,因为pandas不会报错,只是匹配不上,结果里全是NaN。
常见情况:左表的用户ID是整数1001,右表的用户ID是字符串'1001'。merge时pandas会认为它们不相等,匹配失败。
排查方法:合并前打印两张表关联键的数据类型。
print(df_left['用户ID'].dtype) print(df_right['用户ID'].dtype)如果不一致,用astype统一。我通常统一成字符串,因为字符串能兼容数字和字母混合的ID。
6.3 concat拼接后索引重复的处理
concat不加ignore_index=True时,结果的索引是原索引的简单堆叠。如果原索引有重复,拼接后重复更严重。
处理方式有两种:一是拼接时加ignore_index=True;二是拼接后用reset_index(drop=True)。两种效果一样,我习惯在拼接时就加上,省得后面忘。
但如果用了keys参数,就不能加ignore_index=True,因为多层索引是有意义的。这时候可以在拼接后reset_index,然后用droplevel把原来的索引层去掉。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 合并后行数暴涨 | 关联键重复 | df['键'].is_unique | 去重或改用聚合 |
| 合并后大量NaN | 关联键格式不一致 | 检查dtype | 用astype统一类型 |
| 拼接后索引重复 | 未重置索引 | 查看df.index | 加ignore_index=True |
| 合并后列名带后缀 | 同名列冲突 | 查看df.columns | 用suffixes显式指定 |
| 拼接后数据类型变化 | 列类型不一致 | df.dtypes | 拼接前统一类型 |
| 合并后内存溢出 | 数据量过大 | 查看内存占用 | 分批合并或先聚合 |
6.5 几个我踩过的坑
第一个坑:用merge时没注意how的默认值是inner。有一次我合并订单表和用户表,想保留所有订单,结果忘了写how='left',默认走了inner,把没有用户信息的订单全丢了。发现的时候已经跑完一轮分析,只能重来。
第二个坑:concat拼接时列顺序不一致。df1的列是['A', 'B'],df2的列是['B', 'A'],拼接后pandas会按列名对齐,结果没问题。但如果列名相同但含义不同(比如两个表的“金额”列一个是含税价一个是不含税价),拼接后就会混淆。所以拼接前一定要确认列的业务含义一致。
第三个坑:merge后直接修改结果,影响了原始表。pandas的merge返回的是新对象,但如果你用了inplace=True或者直接对结果赋值,有时候会触发视图与副本的问题。我的习惯是合并后立刻用.copy()创建一个独立副本,避免后续操作意外修改原始数据。
7. 一些提高效率的实操习惯
7.1 合并前先做数据质量检查
我现在养成了一个习惯:任何merge或concat之前,先跑一段检查代码。
def check_before_merge(left, right, key): print(f"左表行数: {len(left)}") print(f"右表行数: {len(right)}") print(f"左表键唯一: {left[key].is_unique}") print(f"右表键唯一: {right[key].is_unique}") print(f"左表键类型: {left[key].dtype}") print(f"右表键类型: {right[key].dtype}") print(f"左表键空值数: {left[key].isnull().sum()}") print(f"右表键空值数: {right[key].isnull().sum()}")这段代码花不了几秒钟,但能提前发现80%的合并问题。尤其是空值检查,关联键里有空值时,merge的行为可能不符合预期,提前处理掉更稳妥。
7.2 用pipe把合并流程串起来
当合并步骤比较多时,用pipe可以让代码更清晰。
def merge_users(df): return pd.merge(df, users, on='用户ID', how='left', validate='many_to_one') def merge_products(df): return pd.merge(df, products, on='商品ID', how='left', validate='many_to_one') final = (orders .pipe(merge_users) .pipe(merge_products) .reset_index(drop=True))这种写法比嵌套的merge调用可读性高很多,而且每一步都可以单独测试。
7.3 合并后的验证不能省
合并完成后,我至少会做三个验证:行数是否符合预期、关联键是否有空值、数值列的范围是否合理。
行数验证前面说过了。关联键空值验证是看合并后关联键列有没有NaN,如果有,说明有些记录没匹配上。数值列范围验证是看合并后的金额、数量等列有没有异常值,因为合并可能引入重复记录导致数值翻倍。
这三个验证加起来不到一分钟,但能帮你避免把错误数据带入下游分析。
8. 关于性能的一点经验
当数据量到百万行级别时,merge的性能会成为瓶颈。我试过几种优化方式,效果比较明显的有两个。
第一个是减少参与合并的列。如果右表有20列,但你只需要3列,先在右表上做列裁剪再合并。pandas的merge会复制所有列,减少列数能显著降低内存占用。
第二个是把关联键设为索引。merge时如果用索引作为关联键(left_index=True或right_index=True),速度会比用普通列快,因为索引是排好序的。但要注意,设置索引本身也有开销,如果只合并一次,这个开销可能抵消掉性能收益。
还有一个经验是:能用concat就别用merge。如果两张表的结构可以通过简单的行堆叠解决,concat的性能远好于merge。我见过有人为了把两张同构表拼在一起,硬是用merge加了个自增ID做关联,结果慢了好几倍。
最后说一个我实际项目里的数字:一张500万行的订单表和一张100万行的用户表做left merge,在普通办公电脑上耗时约12秒,内存峰值约2.5GB。如果把用户表裁剪到只保留关联键和需要的3列,耗时降到7秒,内存峰值降到1.8GB。这个优化幅度在迭代分析时很值得做。