做数据分析这行,只要用过一段时间 Pandas,几乎都绕不开一个场景:手里攥着一个 DataFrame,想按行处理点东西。新手期最直觉的写法就是for index, row in df.iterrows(),代码跑起来也没报错,于是这个习惯就跟着走了好几年。直到某天处理一个几十万行的表,发现光遍历就卡了十几秒,才开始回头翻文档,才发现 Pandas 官方在iterrows()的说明里其实写得很直白——它慢,而且会做类型转换,能不用就别用。
这篇东西就是把我自己踩过的坑、做过的对比测试、以及最后沉淀下来的选型经验整理出来。核心围绕三个迭代方法:iterrows()、itertuples()、items()。它们看起来都是"遍历 DataFrame",但底层实现、性能表现、适用场景差别非常大,用错了轻则慢十倍,重则数据被悄悄改了类型你还不知道。适合已经会用 Pandas 做基础操作、但想搞清楚"到底该用哪个"的中级使用者,也适合刚接手别人代码、看到满屏iterrows想重构的开发者。下面从设计思路讲到实操对比,再到排查技巧,尽量把每个"为什么"都讲透。
1. 三种迭代方式的设计逻辑与选型考量
1.1 为什么 Pandas 要提供三种"遍历"入口
要理解这三个方法,得先明白 Pandas 的底层数据结构。DataFrame 本质上是按列存储的——每一列是一个独立的 NumPy 数组(或 ExtensionArray),列与列之间通过索引对齐。这个设计决定了 Pandas 最擅长的操作是列级向量化运算,而不是逐行扫描。你可以把它想象成一张 Excel 表,但底层其实是"一摞按列叠好的卡片",而不是"一行行排好的记录"。
那为什么还要提供行迭代的接口?因为现实里总有些逻辑没法向量化。比如要根据当前行的值去查另一张表、要调用一个只接受标量的外部函数、要做带状态的行间累积计算。这些场景下,你不得不把数据"拉成一行一行"来处理。Pandas 提供三个入口,本质上是在便利性和性能之间给出不同档位的选择:
iterrows():返回(index, Series),最符合直觉,但每行都要构造一个 Series 对象,开销最大,且会做 dtype 推断导致类型可能被"升格"。itertuples():返回命名元组(namedtuple),每行是一个轻量元组,几乎不构造额外对象,速度快一个数量级。items():按列迭代,返回(列名, Series),适合"逐列处理"而非"逐行处理"的场景。
我个人的理解是:iterrows()是给"写起来最顺手"准备的,itertuples()是给"性能敏感"准备的,items()则是被很多人忽略、但在列处理场景下非常趁手的工具。选型的核心判断标准只有一条——你到底是按行想问题,还是按列想问题。
1.2 一个容易被忽略的前提:能向量化就别迭代
在展开三个方法之前,必须先泼一盆冷水:绝大多数你以为"必须遍历"的需求,其实都能向量化。这是选型的第一道关卡,过不了这关,讨论用哪个迭代方法都是舍本逐末。
举个最常见的例子。假设要新增一列,值是另外两列之和。新手写法是遍历每一行相加,老手直接df['c'] = df['a'] + df['b']。后者底层走的是 NumPy 的 C 循环,前者是 Python 层的 for 循环加对象构造,差距在数据量大时是几十倍甚至上百倍。
再比如条件赋值。遍历里写if row['score'] > 60: ...,向量化写法是df.loc[df['score'] > 60, 'grade'] = 'pass'。字符串处理、日期提取、分组聚合,Pandas 都有对应的向量化或apply接口。
提示:判断能不能向量化的土办法——如果你的循环体里只用到"当前这一行"的数据,且不依赖上一行的结果、不调用外部有状态函数,那大概率能向量化。只有当你需要跨行状态、外部 IO、或者复杂分支逻辑时,才真正需要迭代。
我见过太多项目,性能瓶颈就卡在一个本可以向量化的iterrows循环上。所以下面的所有讨论,都建立在"确实需要迭代"这个前提之上。
1.3 三者的底层差异决定了性能天花板
把三者的返回结构摊开看,性能差异的根源一目了然。
iterrows()每次迭代做这几件事:从各列数组里取出第 i 个元素,组装成一个 Series,Series 还要带上原 DataFrame 的列名作为索引标签,同时根据取出的值推断 dtype。构造 Series 是个相对重的操作,涉及索引对象创建、类型推断、内存分配。数据量大时,这个开销会线性累积。
itertuples()走的是另一条路:它把每行的值打包成一个 namedtuple,字段名就是列名(非法标识符的列名会被重命名或跳过,这点后面细说)。namedtuple 是 Python 标准库里的轻量结构,创建成本极低,访问字段走的是属性查找,比 Series 的标签索引快得多。官方文档明确说它"通常比 iterrows 快得多",实测也确实如此。
items()则是完全不同的维度——它迭代的是列。每次返回列名和该列的 Series(注意这里是整列,不是单行)。它的开销主要在返回整列 Series 上,但因为列数通常远小于行数,总迭代次数少,所以整体开销可控。它适合的场景是"对每一列做统一处理",比如逐列统计缺失率、逐列做类型转换。
下面这张表是我自己整理的三者对照,建议收藏:
| 维度 | iterrows() | itertuples() | items() |
|---|---|---|---|
| 迭代单位 | 行 | 行 | 列 |
| 返回结构 | (index, Series) | namedtuple | (列名, Series) |
| 单次开销 | 高(构造 Series) | 低(构造元组) | 中(返回整列) |
| dtype 保持 | 可能被升格 | 保持原值类型 | 保持列 dtype |
| 相对速度 | 慢(基准 1x) | 快(约 10-50x) | 视列数而定 |
| 典型场景 | 调试、小数据 | 生产环境行处理 | 逐列处理 |
理解了这张表,后面的实操和避坑就有了依据。
2. 核心方法逐个拆解与实操要点
2.1 iterrows():最顺手,也最容易埋雷
iterrows()的签名很简单,返回一个生成器,每次产出(index, Series)。写起来确实舒服,row['列名']直接取值,可读性拉满。但它有两个必须知道的坑。
第一个坑是dtype 升格。这是最隐蔽的问题。DataFrame 里如果某一列是整数类型,但同一行里还有浮点列,iterrows()在构造 Series 时会把整行统一成能容纳所有值的类型,整数可能被转成浮点。我实测过一个例子:一列是int64的 ID,一列是float64的金额,遍历时row['id']拿到的居然是1001.0而不是1001。如果你的后续逻辑依赖整数做字典键或者字符串拼接,就会出问题。
import pandas as pd df = pd.DataFrame({'id': [1001, 1002], 'amount': [12.5, 30.0]}) for idx, row in df.iterrows(): print(type(row['id']), row['id']) # 输出: <class 'numpy.float64'> 1001.0 # 注意 id 从 int 变成了 float第二个坑是性能。前面说过,每行构造 Series 的开销在大数据量下非常可观。我做过一个粗略测试,10 万行、5 列的 DataFrame,纯遍历不做任何操作,iterrows()大约要 1.5 到 2 秒,而itertuples()只要 0.05 秒左右,差距在 30 倍上下。这个倍数会随列数增加而进一步拉大,因为 Series 构造的成本和列数正相关。
那iterrows()还有没有使用价值?有。调试阶段、数据量小(几千行以内)、需要频繁按列名取值且列名不规范时,它依然是最省心的选择。我的习惯是:原型验证用iterrows()快速跑通逻辑,确认无误后再视性能需求迁移到itertuples()。
注意:如果确实要用
iterrows()且关心类型,可以在取值时显式转换,比如int(row['id'])。但这属于打补丁,治标不治本,数据量大时还是建议换方法。
2.2 itertuples():生产环境的首选,但有两个细节要盯
itertuples()返回 namedtuple,默认第一个字段是Index(原索引),后面依次是各列。访问方式从row['列名']变成row.列名,属性访问比字典式查找快,这也是它性能好的原因之一。
for row in df.itertuples(): print(row.Index, row.id, row.amount)它有两个参数值得关注。index=True是默认值,会带上索引列;如果不需要索引,设index=False能再省一点开销,返回的元组第一个字段直接就是第一列。name='Pandas'控制返回的 namedtuple 类型名,一般不用改。
第一个细节是列名合法性。namedtuple 的字段名必须是合法的 Python 标识符,不能以数字开头、不能含空格或特殊字符。如果 DataFrame 的列名是'user id'(带空格)或者'2023'(数字开头),itertuples()会把这些列重命名为_1、_2这样的位置名,或者直接跳过。这意味着你按row.user_id取值会报AttributeError。解决办法有两个:迭代前先把列名规范化(df.columns = df.columns.str.replace(' ', '_')),或者用位置索引row[1]取值。
第二个细节是它不做 dtype 升格。因为 namedtuple 里存的是各列原始的值,整数还是整数,浮点还是浮点。这一点比iterrows()靠谱得多,也是我推荐它在生产环境使用的核心理由之一。
# 列名不规范时的表现 df2 = pd.DataFrame({'user id': [1, 2], '2023': [10, 20]}) for row in df2.itertuples(): print(row) # 输出: Pandas(Index=0, _1=1, _2=10) # 注意列名被替换成了 _1, _2实测下来,itertuples()在 10 万行数据上的遍历耗时稳定在几十毫秒级别,是三者中行迭代的最优解。唯一要付出的代价是可读性略降——row.列名不如row['列名']直观,且列名含特殊字符时要额外处理。
2.3 items():被低估的逐列处理利器
items()迭代的是列,每次返回(列名, Series)。很多人第一次看到它会疑惑:"这跟直接for col in df.columns有什么区别?"区别在于items()直接把整列数据也给你了,省去了一次df[col]的查找。
它的典型场景是逐列做统一处理。比如要统计每列的缺失率、每列的唯一值数量、或者对每列做类型检查。这类需求如果用行迭代去想,会非常别扭;用items()就顺理成章。
for col_name, col_data in df.items(): missing_rate = col_data.isna().mean() print(f"{col_name}: 缺失率 {missing_rate:.2%}")这里有个性能上的认知要纠正:items()返回的 Series 是原列的视图还是副本,取决于具体操作。如果只是读取统计,不会触发复制;但如果对返回的 Series 做修改,不一定会写回原 DataFrame,这点和直接df[col] = ...的行为不同,容易踩坑。我的经验是:用items()做只读分析最安全,要做修改就老老实实回到列赋值。
另外,items()的迭代次数等于列数,通常几十到几百,远小于行数,所以它的总开销天然就低。在列数不多的宽表上,它甚至比任何行迭代都快——因为你根本不需要遍历那么多行。
2.4 三者选型的决策清单
讲完三个方法,给一个我实际工作中用的决策流程,照着走基本不会选错:
- 先问:这个逻辑能不能向量化?能,就别迭代,直接写向量化表达式。
- 不能向量化,且是按行处理:数据量小或调试阶段用
iterrows();生产环境、数据量大用itertuples()。 - 不能向量化,且是按列处理:用
items()。 - 需要跨行状态(比如累积、滑动窗口):优先考虑
itertuples()配合外部变量,或者干脆用shift、cumsum等向量化替代。
这个清单看起来简单,但真正落地时,第 1 步就能筛掉八成以上的迭代需求。剩下的两成里,itertuples()又能覆盖大部分。iterrows()的实际使用比例,在我重构过的代码里通常不到 5%。
3. 完整实操:从慢代码到快代码的重构过程
3.1 场景设定与初始慢实现
光讲理论不够,拿一个真实感强的场景走一遍。假设有一张订单表,字段包括订单号、用户 ID、金额、下单时间。需求是:对每个用户,按时间顺序累计其消费金额,并标记出累计金额首次超过阈值的订单。
这个需求带跨行状态(累计值依赖前面的行),没法简单向量化,是迭代的合理使用场景。先看新手最常见的iterrows()写法:
import pandas as pd df = pd.DataFrame({ 'order_id': range(1, 6), 'user_id': [101, 101, 102, 101, 102], 'amount': [50.0, 80.0, 30.0, 120.0, 90.0], 'order_time': pd.to_datetime([ '2024-01-01', '2024-01-02', '2024-01-02', '2024-01-03', '2024-01-04' ]) }).sort_values(['user_id', 'order_time']).reset_index(drop=True) threshold = 100 cum = {} flags = [] for idx, row in df.iterrows(): uid = row['user_id'] cum[uid] = cum.get(uid, 0) + row['amount'] flags.append(cum[uid] > threshold) df['over_threshold'] = flags这段代码逻辑没问题,跑小数据也对。但注意row['user_id']在这里因为同行有 float 列,可能被升格成 float,虽然本例中 user_id 恰好没受影响(因为 amount 是 float 但 user_id 是 int,Series 会统一成 float),实际取值时uid可能是101.0。用它做字典键,101和101.0在 Python 里哈希相等,所以本例侥幸没出错,但这是运气,不是设计。
3.2 迁移到 itertuples() 的改造
把上面的循环换成itertuples(),改动很小,但收益明显:
cum = {} flags = [] for row in df.itertuples(index=False): uid = row.user_id cum[uid] = cum.get(uid, 0) + row.amount flags.append(cum[uid] > threshold) df['over_threshold'] = flags这里用了index=False,因为逻辑里不需要原索引。row.user_id拿到的是原始 int,row.amount是原始 float,类型干净。在 10 万行数据上,这段改造后的代码比iterrows()版本快 20 倍以上,而且结果完全一致。
改造时有个细节要注意:itertuples()的字段名来自列名,如果列名是user_id这种下划线命名,直接row.user_id就行;如果列名是userId驼峰式,也是合法的,row.userId可用;但如果是user-id带连字符,就会被重命名,得改用位置索引。所以改造前先print(df.columns.tolist())确认一下列名规范,能省不少调试时间。
3.3 进一步优化:能向量化的部分抽出来
上面的循环里,其实"累计"这一步可以用groupby加cumsum向量化,只有"首次超过阈值"的标记需要额外处理。进一步优化:
df['cum_amount'] = df.groupby('user_id')['amount'].cumsum() df['over_threshold'] = df['cum_amount'] > threshold一行groupby().cumsum()就替代了整个手动累计循环,底层走的是高度优化的分组聚合,速度比 Python 循环快一到两个数量级。这就是我反复强调"先想向量化"的原因——很多看起来必须迭代的逻辑,拆解后总有一部分能交给 Pandas 的底层实现。
当然,不是所有跨行逻辑都能这么优雅地向量化。如果业务规则更复杂,比如"累计金额超过阈值后,后续订单打折计算",那可能还是得保留迭代。这时候itertuples()就是兜底方案。我的实践原则是:能用向量化解决的绝不迭代,必须迭代的优先 itertuples。
3.4 性能实测数据与解读
为了让你对差距有直观感受,我构造了三组不同规模的数据做对比测试(纯遍历,循环体只做一次加法,排除业务逻辑干扰):
| 数据规模 | iterrows() | itertuples() | items()(按列) |
|---|---|---|---|
| 1 千行 | 约 15 ms | 约 1 ms | 约 0.5 ms |
| 1 万行 | 约 150 ms | 约 8 ms | 约 0.6 ms |
| 10 万行 | 约 1.6 s | 约 60 ms | 约 0.7 ms |
数据是量级参考,具体机器会有浮动,但趋势很稳定:iterrows()随行数线性增长且斜率大,itertuples()斜率小一个数量级以上,items()因为按列迭代,行数增加几乎不影响它的耗时(列数不变的前提下)。
这个表还揭示了一个反直觉的点:在宽表(列多行少)场景下,items()可能比任何行迭代都快。所以选型不能只看"行还是列",还要看数据的形状。行数远大于列数是常态,此时行迭代是主要矛盾;但如果遇到列数上千的宽表,逐列处理反而更划算。
4. 常见问题排查与避坑经验实录
4.1 类型悄悄变了:最坑的隐形 bug
前面提过iterrows()的 dtype 升格,这里展开讲排查方法。症状是:明明 DataFrame 里是整数,遍历时取出来变成了浮点,导致后续字符串拼接出现'1001.0'这种脏数据,或者字典键匹配失败。
排查思路:在循环体第一行打印type(row['目标列']),如果发现是numpy.float64而预期是numpy.int64,就是踩了这个坑。根治方法是换itertuples(),或者迭代前把该列单独取出来用zip遍历:
for uid, amt in zip(df['user_id'], df['amount']): # uid 保持原始 int 类型 ...zip遍历多列是我个人很喜欢的轻量方案,尤其适合只需要两三列的场景,比itertuples()还直接,且完全没有类型问题。缺点是列多了写起来啰嗦,且拿不到索引。
4.2 迭代时修改 DataFrame 引发的诡异行为
另一个高频坑是:在迭代过程中修改原 DataFrame。比如遍历时给某列赋值、增删行。iterrows()和itertuples()在迭代开始时就基于当时的 DataFrame 状态生成迭代器,迭代中修改原表,轻则修改不生效,重则触发SettingWithCopyWarning或者行为不可预期。
正确做法是:先收集结果,迭代结束后统一赋值。就像 3.1 节里用flags列表收集、循环外再df['over_threshold'] = flags。这个模式几乎适用于所有"迭代中要写回"的场景,既避免了修改冲突,又让逻辑更清晰。
注意:如果确实需要在迭代中读到自己刚写的值,那说明逻辑本身有状态依赖,应该重新设计,而不是硬在循环里改表。
4.3 列名不规范导致的 AttributeError
itertuples()遇到非法列名会重命名,症状是row.原列名报AttributeError。排查方法:先print(df.itertuples().__next__())看一眼实际字段名。解决要么规范化列名,要么用位置索引。
我整理了一个常见问题速查表,遇到问题先对号入座:
| 症状 | 可能原因 | 解决方向 |
|---|---|---|
| 取出的整数变成浮点 | iterrows 的 dtype 升格 | 换 itertuples 或 zip |
| row.列名 报 AttributeError | 列名含空格/特殊字符/数字开头 | 规范化列名或用位置索引 |
| 迭代中赋值不生效 | 迭代器基于旧状态 | 循环外统一赋值 |
| 遍历特别慢 | 用了 iterrows 处理大数据 | 换 itertuples 或向量化 |
| 结果和预期差一位 | index=True 时首字段是索引 | 设 index=False 或跳过首字段 |
4.4 几个提升可维护性的小技巧
最后分享几个实操中沉淀的习惯。第一,迭代前先确认数据形状,df.shape打印一下,行数超过十万就要警惕性能,提前想好是否必须迭代。第二,把迭代逻辑封装成函数,输入是行或元组,输出是处理结果,这样既方便单测,也方便日后从itertuples换成别的实现而不动主流程。第三,给循环加进度提示,数据量大时用tqdm包一层,能直观看到卡在哪,排查性能问题时有奇效。
from tqdm import tqdm results = [] for row in tqdm(df.itertuples(index=False), total=len(df)): results.append(process_row(row))tqdm的开销很小,但在处理几十万行时,那个进度条能帮你判断是"整体慢"还是"某几行特别慢",对定位问题很有帮助。这个技巧我在多个项目里用过,尤其是处理外部接口调用这种单行耗时不均的场景,效果立竿见影。
我个人在实际操作中的体会是,这三个方法没有绝对的好坏,只有合不合适。真正拉开水平差距的,不是记住哪个方法快,而是养成"先问能不能向量化、再问按行还是按列、最后才选具体方法"的思考顺序。这个顺序一旦形成,代码的性能和可维护性会同时上一个台阶。至于iterrows(),我现在基本只在写一次性脚本、数据量几百行的时候用它图个顺手,正式代码里见到它,第一反应就是看看能不能换掉。