1. 为什么还要聊数据帧迭代这件事
刚入行那会儿处理一份几十万行的销售明细,我第一反应就是写个for循环套iterrows(),一行一行读、一行一行算,逻辑写得清清楚楚,跑起来也"能出结果"。直到有一次数据量涨到两百万行,同样的脚本从三分钟变成四十分钟,机器风扇狂转,我才意识到问题不在业务逻辑,而在迭代方式本身。iterrows()、itertuples()、items()这三个方法,几乎是每个用 Pandas 的人都会碰到的"入门三件套",但真正能把它们用对、用出性能差异的人并不多。
这篇内容想解决的就是一个很具体的问题:当你确实需要"逐行"或"逐列"遍历一个 DataFrame 时,到底该选哪个方法,为什么这么选,以及选错之后会付出什么代价。它适合两类人——一类是刚接触 Pandas、习惯用循环思维处理表格数据的新手,另一类是已经写过不少脚本、但从来没认真对比过这几种迭代方式性能差异的实践者。我会把每个方法的底层行为、返回值结构、性能量级、适用边界都拆开讲,配上可以直接复现的代码和实测数据,最后再聊聊那些文档里不会写、只有踩过坑才知道的细节。
需要先摆明一个态度:在 Pandas 里,绝大多数"逐行处理"的需求,其实都不该用迭代来解决。向量化、apply、map、merge、groupby这些手段能覆盖的场景远比想象中多。但现实是,总有一些逻辑天生就是"一行一行"的——比如调用外部接口、写复杂的条件分支、做带状态的累加、逐行落库。这时候迭代方法就成了必需品,而选哪个方法,直接决定了你的脚本是"能跑"还是"跑得动"。
2. 三个方法到底在做什么:先搞清楚返回的是什么
很多人用错这三个方法,根源在于没弄清它们各自返回的数据结构。结构决定用法,用法决定性能。所以在谈性能之前,得先把"你拿到手的东西长什么样"这件事说透。
2.1 iterrows():返回的是 Series,不是行本身
iterrows()每次迭代返回一个(index, Series)元组。注意这里的第二个元素是一个Series 对象,它的索引是原 DataFrame 的列名,值是该行对应列的数据。这个细节极其关键,因为它意味着两件事:第一,每次迭代都会新建一个 Series 对象,有对象创建开销;第二,同一列在不同行之间会发生类型转换。
第二点是最容易被忽略的坑。DataFrame 的每一列有自己独立的 dtype,比如一列是int64,一列是float64,一列是object。但当你把一行取出来变成 Series 时,由于 Series 只能有一个统一的 dtype,Pandas 会做向上转型——整行数据会被提升到能容纳所有值的"最宽"类型。整数列和浮点列混在一起,整数就变成了浮点数;再混进一个字符串,整行可能全变成object。
import pandas as pd df = pd.DataFrame({ "id": [1, 2, 3], "amount": [10.5, 20.0, 30.25], "name": ["A", "B", "C"] }) for idx, row in df.iterrows(): print(idx, row.dtype, type(row["id"]), row["id"])跑一下你会发现,row["id"]打印出来是1.0而不是1,类型是float64。如果你的下游逻辑依赖整数做切片、做字典键、做位运算,这里就会埋雷。我见过有人用iterrows()读 ID 然后拿去查字典,结果因为1.0 != 1的哈希差异导致查不到,排查了半天。
所以iterrows()的定位很明确:适合需要按列名访问、且对类型精度不敏感的场景,比如打印日志、做展示、写一些不追求性能的胶水逻辑。它的可读性是最好的,row["列名"]这种写法对新手最友好,这也是它流传最广的原因。
2.2 itertuples():返回的是 namedtuple,轻量且保类型
itertuples()每次迭代返回一个namedtuple(命名元组)。默认情况下,第一个字段是索引(字段名Index),后面依次是各列的值,字段名就是列名(如果列名不是合法标识符,会被重命名或加位置前缀)。namedtuple 是 Python 标准库里的轻量结构,本质上是 tuple 的子类,访问速度快、内存占用小。
它最大的优势有两个。第一,不做类型向上转型。因为 namedtuple 的每个字段是独立取值的,整数还是整数,浮点还是浮点,不会因为"同一行"就被强行统一类型。第二,性能显著优于 iterrows,因为它不需要为每一行构造一个带索引对齐逻辑的 Series 对象。
for row in df.itertuples(): print(row.Index, row.id, type(row.id), row.amount, row.name)这里row.id是1(int),row.amount是10.5(float),类型保持原样。访问方式从row["列名"]变成了row.列名,属性访问比字典式查找更快。
有一个参数值得单独说:index=False。默认会带上索引字段,如果你不需要索引,加上index=False能省掉一个字段的构造开销,在百万行级别上这个差异是能测出来的。另外,如果列名包含空格、中文、或者和 Python 关键字冲突,itertuples会自动重命名(比如变成_1、_2),这时候就得用位置索引row[1]来访问,可读性会下降,这是它的一个使用约束。
2.3 items():按列迭代,不是按行
items()是一个经常被误解的方法。它不是逐行迭代,而是逐列迭代,每次返回(列名, Series)元组,其中 Series 是这一列的全部数据,索引是原 DataFrame 的行索引。很多人第一次看到它以为能拿来遍历行,结果发现拿到的是整列,方向完全反了。
for col_name, col_series in df.items(): print(col_name, col_series.dtype, len(col_series))它的正确用途是按列做统一处理——比如对每一列做类型检查、统计缺失率、做标准化、生成列级别的描述信息。因为返回的是完整的列 Series,你可以直接对它做向量化操作,而不需要再套一层行循环。这一点很重要:items()的价值不在于"迭代",而在于它给了你一个按列组织的入口,让你能在列这个粒度上做批量处理。
三个方法的核心差异,用一张表能看得最清楚:
| 方法 | 迭代方向 | 每次返回 | 类型保持 | 相对性能 | 典型用途 |
|---|---|---|---|---|---|
iterrows() | 按行 | (index, Series) | 会向上转型 | 最慢 | 展示、日志、简单胶水逻辑 |
itertuples() | 按行 | namedtuple | 保持原类型 | 较快 | 逐行计算、条件分支、外部调用 |
items() | 按列 | (列名, Series) | 保持列 dtype | 取决于列内操作 | 列级统计、类型检查、批量变换 |
3. 性能差距到底有多大:一次可复现的实测
光说"itertuples 比 iterrows 快"没有说服力,得拿出可复现的数字。我设计了一个简单的基准测试:构造一个 10 万行的 DataFrame,包含整数、浮点、字符串三列,分别用三种方式遍历并做一个简单的累加,记录耗时。这个测试不是为了得出绝对精确的结论(不同机器、不同 Pandas 版本会有差异),而是为了让你直观感受量级上的差距。
3.1 测试代码与结果
import pandas as pd import time n = 100_000 df = pd.DataFrame({ "id": range(n), "amount": [i * 0.5 for i in range(n)], "tag": ["t" + str(i % 10) for i in range(n)] }) # 方式一:iterrows start = time.perf_counter() total = 0.0 for idx, row in df.iterrows(): total += row["amount"] t1 = time.perf_counter() - start # 方式二:itertuples start = time.perf_counter() total = 0.0 for row in df.itertuples(index=False): total += row.amount t2 = time.perf_counter() - start # 方式三:items 按列(这里只累加 amount 列) start = time.perf_counter() total = df["amount"].sum() t3 = time.perf_counter() - start print(f"iterrows: {t1:.4f}s") print(f"itertuples: {t2:.4f}s") print(f"向量化sum: {t3:.4f}s")在我这台普通开发机上跑出来的典型结果是:iterrows大约 1.5 到 2 秒,itertuples大约 0.05 到 0.08 秒,向量化sum大约 0.001 秒。也就是说,itertuples 比 iterrows 快 20 到 30 倍,而向量化又比 itertuples 快一到两个数量级。这个差距在 10 万行时已经很明显,到百万行、千万行时会放大成"分钟级 vs 秒级"的区别。
注意:上面的
items那一栏我故意换成了向量化sum,因为items()本身是列迭代,拿它和行迭代比"逐行累加"并不公平。items()的正确对比对象是"按列做批量操作",它的性能取决于你在列 Series 上做了什么,而不是迭代本身。
3.2 为什么 iterrows 这么慢:拆开看开销来源
iterrows慢不是玄学,开销来源很具体。第一,每行构造一个 Series 对象,对象创建、内存分配、垃圾回收都是成本;第二,Series 需要维护索引,每次构造都要把列名作为索引挂上去,这是一次索引对齐操作;第三,类型向上转型需要做类型推断和转换,尤其是混合类型行,转换开销更大;第四,row["列名"]是带哈希查找的字典式访问,比属性访问慢。
itertuples把这些开销几乎全省了:namedtuple 是编译期就确定结构的轻量对象,字段访问是属性查找(本质是位置索引),没有索引对齐,没有类型转换。所以它的速度接近"纯 Python 遍历一个元组列表",这已经是纯 Python 层面能做到的较好水平了。
但即便itertuples再快,它仍然是Python 层面的循环。Python 解释器执行一次循环体的开销,和 C 层面一次向量化运算的开销,差着数量级。这就是为什么向量化永远应该作为第一选择——它不是"更快一点的循环",而是"根本不在同一个维度上比较"。
3.3 一个容易被忽略的成本:内存
性能不只是时间,还有内存。iterrows在迭代过程中会不断创建和销毁 Series 对象,如果循环体里还把这些 Series 存进了列表(比如rows = [row for _, row in df.iterrows()]),内存会迅速膨胀——因为每个 Series 都带着自己的索引结构,比原始数据大得多。我见过有人用这种方式"把 DataFrame 转成字典列表",结果内存占用翻了好几倍。
itertuples返回的 namedtuple 就轻量得多,即使全部收集起来,内存占用也接近原始数据的元组表示。如果确实需要把行转成字典列表,更推荐df.to_dict("records"),这是专门为这个需求优化的方法,比手动迭代快得多也省得多。
4. 选型决策:什么场景用哪个,别再用错
知道了原理和性能,接下来是落地。我把常见场景整理成了一张决策表,你可以对着自己的需求直接查。核心原则只有一条:能用向量化就不用迭代,必须迭代时优先 itertuples,iterrows 只留给可读性优先的轻量场景,items 专门处理列级任务。
4.1 场景对照与推荐方案
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 对某列做数学运算 | 向量化 /apply | 快一到两个数量级 |
| 多列条件组合生成新列 | np.where/np.select | 避免逐行判断 |
| 逐行调用外部接口 | itertuples | 保持类型、开销小 |
| 逐行写数据库 | itertuples+ 批量提交 | 减少对象构造 |
| 打印/展示每行内容 | iterrows | 可读性最好 |
| 按列统计缺失率 | items()或df.isna().mean() | 列级操作 |
| 按列做类型转换 | items()配合astype | 列粒度入口 |
| 带状态的累加(依赖上一行) | itertuples或shift | 状态逻辑难向量化 |
这张表里最值得强调的是"带状态累加"这一类。有些逻辑天生依赖前一行结果,比如计算累计持仓、做递归标记、模拟状态机,这类确实很难纯向量化。这时候itertuples是行迭代里的较优解,但即便如此,也要尽量把循环体写薄——循环里只做必要的状态更新,把能提前算好的东西挪到循环外。
4.2 一个真实的重构案例
早前有个需求:读一份订单表,对每一行判断"金额是否超过阈值且客户等级为 VIP",满足则打标。最初的写法是iterrows逐行判断,几十万行跑了好几秒。重构时我先用向量化改写:
df["flag"] = (df["amount"] > 1000) & (df["level"] == "VIP")一行代码,耗时从秒级降到毫秒级。但后来需求变复杂了——打标逻辑要调用一个外部规则引擎,每行传参不同,无法向量化。这时候才退回itertuples:
flags = [] for row in df.itertuples(index=False): flags.append(rule_engine.check(row.amount, row.level)) df["flag"] = flags注意这里我把结果先收集到列表,最后一次性赋值给列,而不是在循环里df.loc[idx, "flag"] = ...。后者是另一个常见性能陷阱——在循环里逐行修改 DataFrame 会触发频繁的块更新甚至碎片化,比收集后统一赋值慢得多。这个细节后面还会展开。
4.3 items() 的正确打开方式
items()最容易被低估。它的价值在于给你一个"按列"的视角,让你能在列粒度上做统一处理。比如批量检查每列的类型和缺失情况:
for col, series in df.items(): print(f"{col}: dtype={series.dtype}, missing={series.isna().sum()}")或者对数值列统一做标准化:
for col, series in df.items(): if pd.api.types.is_numeric_dtype(series): df[col] = (series - series.mean()) / series.std()这里有个细节:循环里对df[col]赋值是安全的,因为你是整列替换,不涉及行级碎片化。但要注意series是迭代开始时拿到的快照引用,如果在循环中修改了df的其他列,可能会影响后续判断,稳妥做法是先收集要处理的列名,再统一处理。
5. 实操中的坑与排查技巧
这一节是我最想写的部分,因为文档里不会告诉你这些,只有真正在项目里跑过、被坑过的人才有体会。下面这些坑,我几乎每一个都踩过至少一次。
5.1 循环里逐行赋值:最隐蔽的性能杀手
新手最常写的模式是这样的:
for idx, row in df.iterrows(): df.loc[idx, "new_col"] = row["a"] + row["b"]这段代码"能跑",但慢得离谱。原因在于df.loc[idx, "new_col"] = ...每次都在做带标签的定位和写入,可能触发底层块的重新分配。数据量大时,这个开销会远超迭代本身。正确做法是先在循环外准备好一个列表,循环里只append,循环结束后一次性赋值:
results = [] for row in df.itertuples(index=False): results.append(row.a + row.b) df["new_col"] = results这个改写通常能带来数倍甚至十倍以上的提速。我实测过一个 50 万行的场景,逐行loc赋值要十几秒,收集后统一赋值不到一秒。
提示:如果循环体里必须读当前行的其他列,用
itertuples的属性访问比iterrows的字典访问快;如果必须用iterrows,至少把row["列名"]的结果提前存到局部变量,避免重复查找。
5.2 类型转换引发的静默错误
前面提过iterrows的类型向上转型,这里给一个更具体的排查思路。如果你发现迭代出来的整数变成了浮点、或者 ID 对不上,第一反应应该是检查row.dtype。一个快速的验证方法是打印type(row["列名"]),如果和预期不符,就说明发生了转型。
解决办法有两个:一是改用itertuples,从根上避免转型;二是如果必须用iterrows,在访问时显式转换,比如int(row["id"])。但显式转换只是补救,治本还是换方法。我个人的经验是,只要循环体里对类型敏感,一律用 itertuples,省得后面排查。
5.3 列名不规范导致 itertuples 字段名错乱
itertuples的字段名来自列名,但列名必须符合 Python 标识符规则。如果列名是"订单 金额"(带空格)、"class"(关键字)、或者纯数字,itertuples会自动重命名成_1、_2这种位置名。这时候row.订单金额就访问不到了,得用row[1]。
排查方法很简单:先跑一次print(df.columns.tolist())看列名,再跑一次print(df.head(1).itertuples())看实际字段名。如果发现字段名是下划线加数字,就说明被重命名了。稳妥的做法是在迭代前用df.rename(columns={...})把列名规范成合法标识符,或者干脆用index=False配合位置索引访问。这个坑在中文列名的表里特别常见,务必留意。
5.4 items() 迭代时修改列引发的意外
items()返回的 Series 是列的视图还是副本,取决于 Pandas 版本和底层内存布局。如果你在迭代过程中修改了df的结构(比如增删列),后续迭代拿到的可能是过期数据,甚至报错。安全做法是先收集列名列表,再遍历列表去访问df[col],而不是直接迭代df.items()的同时改结构。
cols = df.columns.tolist() for col in cols: series = df[col] # 处理逻辑这样即使循环中新增了列,也不会影响正在进行的遍历。这个习惯看起来啰嗦,但在复杂的数据清洗脚本里能避免很多诡异问题。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 迭代出的整数变成浮点 | iterrows 类型向上转型 | 改用 itertuples,或显式 int() 转换 |
| 循环极慢,CPU 不高 | 循环内逐行 loc 赋值 | 收集到列表后统一赋值 |
| itertuples 属性访问报错 | 列名非合法标识符被重命名 | 打印字段名,用位置索引或先重命名列 |
| 内存暴涨 | 收集了大量 Series 对象 | 改用 to_dict("records") 或 itertuples |
| items 迭代结果不一致 | 迭代中修改了 df 结构 | 先收集列名再遍历 |
| 循环里查字典查不到 | 类型转型导致哈希不匹配 | 统一类型后再查,或改用 itertuples |
6. 把迭代用对:几条压箱底的经验
聊了这么多方法和坑,最后分享几条我在实际项目里沉淀下来的经验,都是那种"知道了能少走弯路"的东西。
第一条,先问自己"这个循环能不能不写"。每次我准备写for循环遍历 DataFrame 时,都会先停三秒,想想有没有向量化、apply、merge、groupby的替代方案。十次里有七八次是有的。迭代应该是最后手段,不是第一反应。这个思维习惯的转变,比记住任何性能数字都值钱。
第二条,如果非要迭代,把循环体写薄。循环里只做真正依赖"逐行状态"的事,所有能提前算的、能批量算的,全部挪到循环外。我见过有人在循环里做字符串拼接、做正则匹配、做日期解析,这些其实都能提前向量化处理,结果循环体臃肿,性能全耗在重复计算上。
第三条,用 itertuples 时记得 index=False。这个参数看起来不起眼,但在百万行级别上能省掉一个字段的构造。更重要的是,它让返回结构更干净,你不用担心Index字段和某个真实列名冲突。
第四条,items() 是列级批处理的入口,不是行迭代的替代品。想清楚你要处理的是"每一行"还是"每一列",方向错了,后面全错。按列处理时,拿到 Series 后尽量用向量化方法操作它,而不是再套一层行循环——那就等于绕了一圈又回到低效路径。
第五条,性能问题先用小数据验证,再上大数据。我习惯用 1 万行做逻辑验证,用 100 万行做性能验证。逻辑对不对和快不快是两件事,分开测能更快定位问题。如果 1 万行就要好几秒,那 100 万行基本没法用,趁早重构。
这些经验没有一条是"标准答案",都是具体项目里磨出来的。Pandas 的迭代方法本身不复杂,复杂的是判断"什么时候该用、怎么用才不亏"。把这三个方法的边界摸清楚,再配合向量化的思维,大部分数据处理脚本的性能问题都能提前避开。至于那些确实绕不开的逐行逻辑,用itertuples把循环写薄、把赋值挪到循环外,基本就能把性能控制在可接受的范围内了。