Pandas迭代性能优化:iterrows、itertuples与items选型指南
做数据处理的人几乎都绕不开一个场景:手里攥着一个几十万行的 DataFrame,需要逐行做点带条件的加工,比如根据某几列的组合值去查一张映射表、拼一段文本、或者调用一个不方便向量化的外部函数。这时候脑子里蹦出来的第一个念头往往是for index, row in df.iterrows():,代码写起来顺手,读起来也直观。但我见过太多项目,这段循环跑起来之后,原本几秒钟能出结果的任务硬生生拖到了几分钟甚至十几分钟,机器风扇呼呼转,人只能干等着。
问题不在于"迭代"这件事本身有错,而在于很多人默认了iterrows()是唯一选项,甚至以为它和itertuples()、items()只是写法上的口味差异。实际上这三者在底层的数据组织方式、内存开销、类型保持能力上差别相当大,选错了轻则慢一个数量级,重则把整数悄悄变成浮点数、把日期变成字符串,埋下一堆难以察觉的脏数据隐患。这篇内容就围绕 Pandas 里这三种最常用的迭代方式展开,把它们的机制、适用边界、实测差异和踩坑经验一次讲透,适合已经会用 Pandas 做基本增删改查、但还没系统梳理过迭代性能的读者,也适合那些被"循环太慢"困扰、想搞清楚到底该用哪个方法的人。
1. 为什么"逐行处理"在 Pandas 里天然是个敏感话题
1.1 向量化才是 Pandas 的主场,迭代是退而求其次
要理解三种迭代方法的取舍,得先接受一个前提:Pandas 的设计哲学是列式存储 + 向量化运算。它的底层大量依赖 NumPy 的连续内存块和 C 语言实现的循环,当你写df['a'] + df['b']时,真正干活的是编译好的底层代码,Python 解释器只负责发号施令。而一旦你写出for ... in df.iterrows(),控制权就交回给了 Python 解释器,每一行都要经历一次对象创建、一次类型装箱、一次函数调用,原本在 C 层能一口气跑完的事情被拆成了成千上万次解释器往返。
我用一个很朴素的类比来说明:向量化像是让一辆卡车一次性把货拉走,迭代则像是让卡车每装一箱就往返一趟。货少的时候看不出差别,货一多,往返的路费(解释器开销)就彻底压过了运输本身。所以从业者的第一反应应该是:能用向量化、apply、map、np.where解决的,就别上显式循环。迭代方法的存在意义,是留给那些确实无法向量化的场景——比如要调用一个只接受标量参数的第三方库函数、要按行做带副作用的写操作、或者逻辑分支复杂到用向量化表达反而不可读。
1.2 三种方法各自的"身份定位"
在动手比较之前,先把三者的定位摆清楚,后面所有的性能和行为差异都能从这里推导出来。
| 方法 | 每次迭代产出 | 底层数据形态 | 典型用途 |
|---|---|---|---|
iterrows() | (index, Series)元组 | 每行现造一个 Series | 需要按列名访问、逻辑简单的小数据 |
itertuples() | 命名元组(namedtuple) | 复用行数据、轻量结构 | 追求速度、按属性或位置访问 |
items() | (列名, Series)元组 | 按列迭代,整列一个 Series | 需要逐列处理、列级聚合 |
iterrows()返回的每一行是一个独立的Series,索引是列名。这个 Series 有个致命特点:它会把整行的数据类型统一"提升"到能容纳所有值的公共类型。一行里既有整数又有浮点数,整数就被迫变成浮点;一行里混了字符串和数字,整行可能退化成object。这就是很多人迭代完发现"我的 ID 怎么变成 1001.0 了"的根源。
itertuples()返回的是namedtuple,默认第一个字段是索引(可用index=False关掉),后面按列顺序排列。它不构造 Series,直接把底层数组的值塞进一个轻量元组,所以速度快、内存省,而且类型保持得更好——整数还是整数,不会因为同行的浮点数被牵连。代价是不能用row['列名']这种字典式访问,得用row.列名或row[1]。
items()容易被误解,它不是"逐行",而是逐列。每次产出一个(列名, 整列Series)。它的适用场景和前两者根本不同:当你需要对每一列单独做统计、类型转换、或者把每列喂给不同的处理函数时,items()才是对的工具。拿它去逐行处理,属于用错了方向。
2. iterrows 的真实开销:一次迭代到底发生了什么
2.1 拆解一次 iterrows 的完整链路
很多人对iterrows()慢的认知停留在"听说它慢",但说不清慢在哪。我把一次迭代拆开看:Pandas 内部会遍历底层数据块,对每一行新建一个 Series 对象,把该行各列的值逐个取出、装箱成 Python 对象、再塞进这个 Series 的底层数组。也就是说,10 万行的表,就要造 10 万个 Series,每个 Series 都有自己的索引对象、数据缓冲区、元数据。这还没算你在循环体里访问row['某列']时,Series 又要做一次索引查找。
这里有个容易被忽略的细节:iterrows()返回的 Series 索引是原 DataFrame 的列名,但它的dtype是整行统一的。我做过一个对照实验,构造一张包含id(int64)、price(float64)、name(object)三列的表,迭代时打印row.dtype,结果是object——因为name列是字符串,整行被迫降级成 object。这时候你再去row['id'],拿到的可能已经不是原来的整数语义了。这种隐式的类型污染,在数据量小的时候不容易发现,等下游做数值比较或入库时才会突然报错。
2.2 一个能直接复现的类型陷阱
我建议每个用iterrows()的人都亲手跑一遍下面这段代码,印象会比看十篇文章都深:
import pandas as pd df = pd.DataFrame({ "id": [1001, 1002, 1003], "score": [88.5, 92.0, 79.5], "tag": ["a", "b", "c"], }) for idx, row in df.iterrows(): print(idx, row["id"], type(row["id"]), row.dtype)跑完你会看到row["id"]打印出来是1001,但type是float64对应的 Pythonfloat,而row.dtype是object。如果这张表里没有tag这列字符串,只有 int 和 float,那row.dtype会变成float64,id依然是浮点。只要一行里存在浮点列,整数列在 iterrows 里就保不住整数身份。这个行为不是 bug,是 Series 同质化存储的必然结果,但它足以让一个依赖整数主键的下游流程崩掉。
提示:如果你的循环体里需要拿原始整数做字典键、做精确相等判断、或者拼 SQL 主键,
iterrows()基本可以直接排除,换itertuples()。
2.3 iterrows 并非一无是处
说了这么多缺点,也得给它一个公道。iterrows()的可读性确实是三者里最好的:row['列名']这种写法对刚接触 Pandas 的人最友好,代码意图一目了然。在数据量只有几千行、逻辑分支又特别复杂(比如要根据五六个列的组合走不同分支、每个分支还要调不同的外部函数)的场景下,用iterrows()换来的可维护性,往往比那点性能损失更值。我个人的判断线是:行数在万级以内、且逻辑无法优雅向量化时,iterrows 可以用;一旦上了十万级,就得认真考虑换 itertuples 或者重构逻辑。
3. itertuples 凭什么快:命名元组的轻量哲学
3.1 省掉的是"造 Series"这笔大头开销
itertuples()快的核心原因就一句话:它不造 Series。它把每一行的值直接打包成一个namedtuple,而 namedtuple 是 Python 标准库里极其轻量的结构,本质是一个字段名固定的元组,创建成本远低于 Series。没有索引对象、没有数据缓冲区、没有 dtype 推断,就是几个值按顺序排好。
我做过一个粗略的实测(同一台机器、同一份数据、同样的循环体只做累加),10 万行三列的表:
| 方法 | 耗时(相对值) | 内存峰值感受 |
|---|---|---|
iterrows() | 约 1.0(基准) | 高,Series 频繁创建回收 |
itertuples() | 约 0.15 ~ 0.25 | 明显更低 |
items()(逐列) | 视列数而定,列少时很快 | 中等 |
具体倍数会随列数、数据类型、机器状态浮动,但"itertuples 比 iterrows 快数倍"这个结论是稳定的。列越多,iterrows 造 Series 的成本越高,差距越明显。
3.2 属性访问与位置访问的取舍
itertuples()默认返回的 namedtuple 第一个字段叫Index(就是行索引),后面按列顺序。访问方式有两种:
for row in df.itertuples(): # 属性访问,可读性好 print(row.Index, row.id, row.score) for row in df.itertuples(index=False): # 关掉索引列,按位置访问 print(row[0], row[1], row[2])这里有个实操细节值得说:列名如果包含空格、特殊字符、或者以数字开头,属性访问会失效,因为 namedtuple 的字段名必须是合法标识符。遇到这种列名,要么提前df.rename()改成合法名,要么老老实实用位置访问row[i]。我踩过一次坑,某张表的列名是订单 金额(中间带空格),row.订单 金额直接语法错误,排查了半天才反应过来是命名元组的字段名限制。
另外,itertuples()有个name参数可以给返回的 namedtuple 起个类名,默认是Pandas。这个参数平时用不上,但在调试时打印type(row)看到Pandas会觉得莫名其妙,知道有这么回事就行。
3.3 类型保持:整数终于是整数了
回到前面那个类型陷阱,同样的表用itertuples()迭代,row.id拿到的就是 Pythonint,row.score是float,各归各位,不会互相污染。原因很简单:namedtuple 里每个位置存的是从底层数组直接取出的标量,没有经过 Series 的同质化过程。这一点对数据准确性要求高的场景(比如金融金额、库存数量、主键 ID)非常关键。
不过要注意,itertuples()对某些扩展类型(比如带时区的时间戳、可空整数Int64)的处理和iterrows()可能略有差异,遇到这类列建议先小样本验证一下取出的值是否符合预期,别直接上全量。
4. items 被误解的角色:它是逐列,不是逐行
4.1 逐列迭代的正确打开方式
items()的签名是DataFrame.items(),迭代产出(列名, Series),其中 Series 的索引是行索引。它天生就是为列级操作设计的。典型场景比如:对每一列做独立的缺失值统计、对每一列按不同规则做类型转换、把每一列分别喂给不同的可视化函数。
for col_name, col_series in df.items(): print(col_name, col_series.dtype, col_series.isna().sum())这段代码一次性把每列的类型和缺失数打出来,比df.dtypes加df.isna().sum()两次遍历更紧凑,逻辑也更集中。当你需要"对每一列做点不一样的事"时,items()的表达力比先拿列名列表再df[col]更自然。
4.2 拿 items 逐行处理是方向性错误
我见过有人写for name, series in df.items(): for i in series: ...来模拟逐行遍历,这属于绕远路。items()每次给你的是整列,你要逐行还得在列内部再套一层循环,等于把"按列组织"的数据强行掰成"按行消费",中间多了一层无谓的拆解。真需要逐行,直接用itertuples()或iterrows(),别用items()硬凑。
反过来说,如果你发现自己的循环体里对每一列做的操作高度一致(比如都是series.fillna(0)),那连items()都不必用,直接df.fillna(0)向量化搞定。items()的价值恰恰在于"每列处理逻辑不同"的时候。
4.3 列级聚合时 items 与向量化的边界
有个中间地带值得单独提:当你需要对每列做聚合,但聚合函数是自定义的、无法直接用df.agg()表达时,items()是个不错的载体。比如每列要按不同阈值做分箱统计:
thresholds = {"score": 90, "price": 100} result = {} for col, series in df.items(): if col in thresholds: result[col] = (series > thresholds[col]).sum()这种"列名驱动不同参数"的逻辑,用items()写出来最直白。但如果阈值对所有列统一,那就该用向量化比较(df > threshold).sum(),一行顶一个循环。
5. 选型决策:把方法匹配到真实场景
5.1 一张决策表帮你快速定位
与其记一堆规则,不如把判断压缩成几个问题,按顺序问自己:
| 你的处境 | 推荐方法 | 理由 |
|---|---|---|
| 逻辑能用向量化/apply 表达 | 都不用,直接向量化 | 快一个数量级 |
| 必须逐行、行数万级以内、逻辑复杂 | iterrows() | 可读性优先,性能可接受 |
| 必须逐行、行数大、或需保持整数类型 | itertuples() | 快数倍,类型不污染 |
| 需要按列处理、每列逻辑不同 | items() | 逐列语义天然匹配 |
| 需要按行但列名不合法 | itertuples(index=False)+ 位置访问 | 绕开字段名限制 |
这张表不是死规矩,但能覆盖八成日常决策。真正需要动脑的是"能不能向量化"这一关,很多人跳过这关直接进循环,才是性能问题的源头。
5.2 一个真实的重构案例
假设有个需求:给一张订单表每行打标签,规则是金额大于 500 且状态为"已完成"标"优质",金额大于 500 但状态未完成标"待跟进",其余标"普通"。新手写法:
labels = [] for idx, row in df.iterrows(): if row["amount"] > 500 and row["status"] == "done": labels.append("优质") elif row["amount"] > 500: labels.append("待跟进") else: labels.append("普通") df["label"] = labels这段代码在 5 万行上大概要跑一两秒,逻辑清楚但没必要。向量化版本:
import numpy as np df["label"] = np.select( [ (df["amount"] > 500) & (df["status"] == "done"), (df["amount"] > 500), ], ["优质", "待跟进"], default="普通", )同样的数据,向量化版本通常在几十毫秒量级。这个案例的意义不是说循环不能用,而是提醒:在跳进循环之前,先花三十秒想想有没有np.select、np.where、map这类工具能一把梭。想不出来再迭代,这时候再在iterrows和itertuples之间做选择。
5.3 什么时候迭代反而是"正确答案"
有些场景向量化真的无能为力,比如循环体里要调用一个只接受单个字符串、内部还要发网络请求或读写文件的函数。这种带副作用、带外部依赖的逻辑,天生就是逐行的。此时选itertuples()拿数据、在循环体里做副作用操作,是干净的做法。我一般会配合tqdm加个进度条,几十万行跑起来心里有数:
from tqdm import tqdm for row in tqdm(df.itertuples(index=False), total=len(df)): process(row.id, row.payload)注意total=len(df)要显式传,否则 tqdm 拿不到总数,进度条只会显示已处理数量而没有百分比。
6. 迭代之外的性能护栏与常见坑
6.1 循环里改原表:SettingWithCopy 与性能双杀
一个高频错误是在迭代过程中直接修改原 DataFrame:
for idx, row in df.iterrows(): df.loc[idx, "new_col"] = row["a"] + row["b"]这段代码有两个问题。第一,每次df.loc赋值都是一次独立的索引定位和写入,几十万行下来开销巨大;第二,如果df是某个切片的结果,还可能触发SettingWithCopyWarning,甚至改了个寂寞(改的是副本)。正确做法是先在循环外把结果收集到一个列表,循环结束后一次性赋值:
results = [] for row in df.itertuples(index=False): results.append(row.a + row.b) df["new_col"] = results列表追加是 O(1) 摊还,最后一次性赋值走的是向量化路径,整体快得多,也避开了切片赋值的坑。
6.2 迭代中删除行是另一个雷区
在iterrows()循环里df.drop(idx)是经典反模式,边迭代边改结构,轻则索引错乱,重则行为未定义。需要按条件删行,正确姿势是先算出要删的索引集合,循环结束后统一df.drop(index=to_drop);或者干脆用布尔索引df = df[~condition]一步到位。迭代只负责"判断",不负责"修改结构",这条边界守住了能省掉大量诡异 bug。
6.3 大数据下的内存与分块思路
当表大到几十万上百万行,即便用itertuples(),一次性把整表读进内存再迭代也可能吃紧。这时候可以考虑分块读取(如果数据源支持)或者用np.array_split把表切成若干块,逐块迭代处理再合并结果。分块的好处是内存峰值可控,坏处是要自己管理块间状态(比如累加器、去重集合)。我一般会在块大小上做个权衡:块太小,循环调度开销占比高;块太大,内存优势不明显。经验值是从几万行一块起步,根据实际内存占用再调。
6.4 别忘了先做类型优化
迭代慢,有时候不全是方法选错,而是表本身的 dtype 太"胖"。object 类型的字符串列在内存里是一堆指针加独立对象,迭代时取值也慢。如果某列其实是低基数类别,转成category能显著降内存;能用int32就别用int64。这些优化在迭代前做一遍,往往比换迭代方法带来的收益还大。我习惯在拿到新表时先跑一遍df.info(memory_usage="deep"),看看哪列在偷偷吃内存,再决定要不要做类型收敛。
7. 我个人的几条实操心得
第一,先问"能不能不迭代",这是所有优化的起点。我现在的习惯是,只要脑子里冒出for ... in df.的念头,就先停三秒,想想np.where、np.select、map、apply、groupby.transform里有没有能替代的。十次里有六七次能找到向量化方案,剩下三四次才真正需要迭代。
第二,默认用itertuples(),除非可读性真的扛不住。它快、类型干净、内存友好,唯一的门槛是属性访问对列名有要求。列名规范的表,我基本无脑选它;列名脏的表,先 rename 再迭代,比在循环里忍受row['带空格的列']更省心。
第三,iterrows()留给小数据和复杂分支。几千行、逻辑绕、需要频繁按列名取值的场景,它的可读性优势是实打实的,没必要为了那点性能把代码写得难维护。性能优化要看绝对收益,几千行的循环快慢差异可能就几十毫秒,不值得牺牲可读性。
第四,items()只在逐列语义成立时用。判断标准很简单:你的循环体是不是"对每一列做一件独立的事"。是,就用它;不是,就别硬套。
第五,迭代里绝不改结构,只收集结果。这条守住了,能避开一大半的诡异 bug 和性能陷阱。收集到列表、循环外一次性赋值,是我这些年最稳定的写法。
最后分享一个小技巧:调试迭代逻辑时,先df.head(20).itertuples()跑一遍,把每行的处理结果打印出来核对,确认逻辑无误再上全量。全量跑之前用tqdm包一层,几十万行跑起来至少知道进度,不至于盯着黑屏干等。这些细节看着琐碎,但真到了生产环境,省下的排查时间都是实打实的。