本节目标:用 50 万行真实数据把 pandas 3.0 的性能陷阱逐个实测——看清 Copy-on-Write 成为默认后链式赋值到底发生了什么、
SettingWithCopyWarning现在还在不在、dtype 怎么决定内存、apply/iterrows为什么慢、groupby/merge有哪些放大,最后给出可落地的取舍清单。
适用版本:Python 3.12+(实测 3.14.6);pandas 3.0.6、numpy 2.5.3
11.1 pandas 数据处理与性能陷阱
pandas 是 Python 数据处理的默认入口,但它也是最容易被误用的库——同一份逻辑,写法差一点,耗时能差上千倍。这一节不讲 API 清单,只讲实测出来的陷阱:先看 3.0 的行为变化(很多人还在按 2.x 的记忆写代码),再用一份 50 万行的订单数据把内存、向量化、聚合、连接逐个量出来。
所有数字都在本机实测,数据集构造如下(后文复用):
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
N = 500_000
cities = ["北京", "上海", "广州", "深圳", "杭州", "成都", "武汉", "西安"]
df = pd.DataFrame({
"order_id": np.arange(N),
"user_id": rng.integers(0, 50_000, N),
"city": pd.Categorical(rng.choice(cities, N)),
"amount": rng.uniform(1, 1000, N).round(2),
"qty": rng.integers(1, 10, N).astype("int16"),
"ts": pd.date_range("2026-01-01", periods=N, freq="s"),
})
11.1.1 pandas 3.0:Copy-on-Write 已经是默认行为
如果你是从 pandas 2.x 过来的,最容易踩的第一颗雷就是赋值语义变了。3.0 把 Copy-on-Write(CoW)变成不可关闭的默认行为:任何从 df 派生出来的对象(切片、布尔索引、df["col"])都表现为「写时复制」,改它不会改回原 df。实测:
import pandas as pd
print(pd.options.mode.copy_on_write) # 打印选项本身会发 Pandas4Warning:已弃用
Pandas4Warning: The 'mode.copy_on_write' option is deprecated. Copy-on-Write can no
longer be disabled (it is always enabled with pandas >= 3.0) ...
True
选项还在、值恒为 True,但设它已经没有任何效果(会在 pandas 4.0 移除)。真正的行为差异在下面两段代码:
df = pd.DataFrame({"a": [1, 2, 3], "b": [10, 20, 30]})
sub = df[df["a"] > 1] # 切片得到的是「写时复制」的视图
sub["b"] = 999 # 只改 sub,不碰 df
print(df); print(sub)
原 df:
a b
0 1 10
1 2 20
2 3 30
子集 sub:
a b
1 2 999
2 3 999
2.x 时代这种写法会发警告甚至改到原表,3.0 下它只改副本、不动原表——语义干净了,但如果你指望它改原表,就会得到「改了没反应」的困惑。
11.1.2 SettingWithCopyWarning 去哪了
网上铺天盖地的「SettingWithCopyWarning 怎么消」已经过时。3.0 里这个警告被移除了,实测:
print(hasattr(pd.errors, "SettingWithCopyWarning")) # -> False
取而代之的是 ChainedAssignmentError,专门针对链式赋值 df["b"][mask] = x 这种写法:
import warnings
df2 = pd.DataFrame({"a": [1, 2, 3], "b": [10, 20, 30]})
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
df2["b"][df2["a"] > 1] = 0
print("警告类别:", w[0].category.__name__)
print(df2)
警告类别: ChainedAssignmentError
a b
0 1 10
1 2 20
2 3 30
关键点:链式赋值既发警告又完全无效——df2 一个字没变。正确写法永远是单步 .loc:
df2.loc[df2["a"] > 1, "b"] = 0 # 一步完成,语义明确,改的就是原表
记法:只要你在写 df[...][...] = ,就是错的;把它改写成 df.loc[行条件, 列] = 。
11.1.3 dtype 决定内存:category 能省 76 倍
处理大表,内存往往比 CPU 先到瓶颈。df.memory_usage(deep=True) 是量内存的入口(deep=True 才会把字符串、对象列的真实字节数算进去)。50 万行的表实测:
mu = df.memory_usage(deep=True)
for col, b in mu.items():
print(f"{col:<12} {b/1024/1024:8.2f} MB")
print(f"{'合计':<12} {mu.sum()/1024/1024:8.2f} MB")
Index 0.00 MB
order_id 3.81 MB
user_id 3.81 MB
city 0.48 MB
amount 3.81 MB
qty 0.95 MB
ts 3.81 MB
合计 16.69 MB
注意 qty 只有 0.95 MB——因为它被显式存成了 int16。默认 int64 会占 3.81 MB。数值列降位(int16/int32/float32)是零成本的省内存手段,前提是取值范围放得下。
city 只有 8 个唯一值,把它存成 category 收益巨大。同一列三种 dtype 实测:
| dtype | 内存(50 万行) | 说明 |
|---|---|---|
category | 0.48 MB | 内部只存「整数码 + 字典」,重复值几乎不额外占空间 |
str(3.0 默认) | 36.72 MB | 每个字符串单独存,重复值也各存一份 |
object | 36.72 MB | 同 str,2.x 时代的默认 |
约 76 倍差距。规律:低基数的字符串列(类别、地区、状态)一律转 category;高基数(用户 ID、订单号)转 category 反而更慢更费内存,别乱用。
这里还藏着一个 3.0 的变化:pd.Series(["a", "b"]).dtype 现在是 str,不再是 object。str 和 object 在无 pyarrow 环境下内存一致,但语义上 str 更明确;如果你的代码里 dtype == "object" 用来判断字符串列,升级到 3.0 后判断会失效,要改成 is_string_dtype()。
11.1.4 向量化 vs apply vs map:差 400 倍
这是最经典的性能分水岭。对 50 万行的 amount 列做 x * 1.1,三种写法实测(各跑 3 次取最快):
.apply(lambda) 42.7 ms
向量化 * 1.1 0.1 ms
.map(lambda) 45.7 ms
apply/map 逐元素调 Python 函数,慢约 400 倍。换成有分支的逻辑更明显:
df["amount"].apply(lambda x: "high" if x > 500 else "low") # 38.8 ms
np.where(df["amount"] > 500, "high", "low") # 0.5 ms
apply 分档 38.8 ms
np.where 分档 0.5 ms
np.where / df.mask / df.select 把条件写成整列运算,交给底层 C/NumPy 循环,比逐行 Python 快两个数量级。判断标准很简单:写代码时你脑子里有没有出现「对每一行……」,只要有,就去找向量化替代。
一个反直觉的实测:字符串列用 .str.upper() 并不总是比 .apply(str.upper) 快。200 万行实测 .str.upper() 188.6 ms、.apply 151.7 ms——.str 访问器有分派开销。所以别迷信「.str 一定更快」,它真正的价值是自动跳过 NaN、语义统一;真正的性能杀手是逐行 apply,不是 .str 本身。
11.1.5 iterrows 是最大的坑
比 apply 更糟的是 iterrows()——它每行构造一个 Series,开销爆炸。2 万行求和实测:
iterrows 2万行: 206.2 ms
向量化 2万行: 0.1 ms
2000 倍。而且 iterrows 还有个隐蔽的坑:它会把每行的 dtype 强制转成公共类型(整数列变成 float),改起来还不会写回原表。结论:生产代码里出现 iterrows/itertuples 基本可以判死刑,改用向量化,或用 df.itertuples()(比 iterrows 快很多但仍远慢于向量化)。真需要逐行循环的场景,往往是逻辑没抽干净。
11.1.6 groupby 与 merge 的陷阱
groupby 聚合本身很快,50 万行按 city 求和实测 7.1 ms:
df.groupby("city", observed=True)["amount"].agg(["sum", "mean", "count"])
注意 observed=True:当分组键是 category 时,默认 observed=False 会为所有类别(哪怕没出现)生成一行空分组,既慢又污染结果。类别列做 groupby 务必显式 observed=True。
顺带纠正一个流传很广的说法:「groupby.apply 比 agg 慢十倍」。在本机 3.0.6 实测两者几乎持平(20 万行:apply 10.8 ms vs agg 9.4 ms),因为 3.0 对 apply 做了优化。能不用 apply 就不用(写起来啰嗦、可控性差),但不必为性能恐慌。
merge 的陷阱在「键不唯一」。50 万行订单左连 5 万行用户表实测:
唯一键 merge 11.0 ms
非唯一键 merge 35.7 ms
非唯一键时,右表每个重复键都会把左表匹配行放大复制——不仅慢,还可能让结果行数暴涨。所以 merge 前先确认右表键唯一:users["user_id"].is_unique,不唯一要么去重、要么明确这是「多对多」的语义。merge 后的行数一定要和预期对一遍,这是数据管道里最常见的静默错误来源。
11.1.7 分块读取:省内存,但不省时间
单表大到内存放不下时,read_csv(..., chunksize=...) 是标准解法——每次只读一块,处理完就丢:
totals = {}
for chunk in pd.read_csv("orders.csv", chunksize=100_000):
g = chunk[chunk["amount"] > 500].groupby("city", observed=True)["amount"].sum()
for k, v in g.items():
totals[k] = totals.get(k, 0.0) + v
同一份 500 万字节级 CSV(50 万行、约 13.8 MB),全量读 vs 分块读 vs polars 惰性实测:
pandas 全量读 : 128.8 ms
pandas 分块读 : 131.4 ms
polars 惰性 : 12.5 ms
分块读几乎没有更快——CSV 解析(把文本转成数值)的成本才是大头,分块只是把同样的解析量拆开做,省的只是峰值内存。所以:内存够就一次性读;内存不够才分块,别指望它提速。真正想提速,要么换 Parquet(列式、免解析),要么用下一节的 polars。
延伸阅读
- Python 数据分析:pandas 与 Polars 实战 —— 数据清洗、透视、时间序列的完整用法
- Python 数据工程与 ETL —— 从单机 pandas 到管道化的完整路径
- Polars 与大规模数据 —— 下一节,用列式引擎把上面的耗时再压一个数量级
小结
- pandas 3.0 的 CoW 不可关闭:切片/布尔索引派生的对象改了不回写原表;链式赋值
df["b"][mask] = x发ChainedAssignmentError且完全无效,一律改写df.loc[行, 列] = x。 SettingWithCopyWarning已被移除(hasattr(pd.errors, "SettingWithCopyWarning")为False),3.0 起字符串列默认 dtype 是str而非object。- dtype 就是内存:低基数类别列转
category省约 76 倍(0.48 MB vs 36.72 MB),数值列降位到int16/float32是零成本优化。 - 向量化碾压逐行:
apply慢约 400 倍、iterrows慢约 2000 倍;有分支用np.where,别写「对每一行……」。 groupby类别键要observed=True;merge怕键不唯一(35.7 ms vs 11.0 ms 且会放大行数),连完必对行数。- 分块读只省内存不省时间(131.4 ms vs 128.8 ms);想真提速得上列式格式或 polars。
这一节我们把「单机 pandas 怎么不踩坑」讲透了。但 pandas 的单线程模型和「先物化再计算」的路径,注定在更大数据量上撞墙。下一节换 polars:用惰性求值、查询优化和列式并行,把同一任务的耗时再压一个数量级。
阅读导航:上一节:心跳、重连与广播 · 下一节:Polars 与大规模数据 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。