写时复制(Copy-on-Write)与赋值安全
本教程共 54 篇 · 第 9 篇 · 更新于 2026-08-11 · 约 7 分钟阅读
本节目标:理解 pandas 3.0 默认开启的写时复制机制,避开链式赋值陷阱,学会安全地修改数据、显式触发复制。
一个”改一个动两个”的老问题
先看一段代码,猜猜结果:
import pandas as pd
df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
subset = df["foo"] # 取出 foo 列
subset.iloc[0] = 100 # 改 subset 的第一个值
print(df)
在 pandas 3.0 之前,df 的第一行第一列会变成 100。原因:subset 是 df 的一个视图(view),两者共享底层数据,改一个等于改两个。
这种”副作用”极其隐蔽。你以为只动了临时变量,结果原始数据悄悄变了,查错能查一晚上。Copy-on-Write(写时复制,简称 CoW)就是为解决这个问题而生的。
CoW 是什么:3.0 默认开启
CoW 的规则一句话就能说清:任何从别的对象派生出来的 DataFrame 或 Series,都表现得像一份独立副本;只有在真正修改时,才复制底层数据。
上面的代码在 3.0 里运行,df 保持原样:
df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
subset = df["foo"]
subset.iloc[0] = 100
print(df) # foo 列还是 1, 2, 3
print(subset) # 只有 subset 变了
一次语句只能更新一个对象,不会再出现”改一个动两个”。
CoW 从 1.5.0 引入,2.x 逐步完善,3.0 起成为默认且唯一的模式。它带来的好处有两个:
- 行为可预测,索引操作和方法调用不会产生副作用
- 复制被尽量推迟,多数方法平均性能更好、内存占用更低
Note旧版本可以用
pd.options.mode.copy_on_write = "warn"提前检查代码兼容性,它会警告每个行为会变的操作。3.0 里这个选项已弃用、设置不再生效(将在 4.0 移除),CoW 没有回头路。
链式赋值:永远不生效了
链式赋值指的是”连着用两次索引再赋值”的写法:
df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
# 这种写法在 3.0 会报错
df["foo"][df["bar"] > 5] = 100
df["foo"] 取出一列(临时对象),再对它做布尔筛选后赋值。这要求同时修改临时对象和 df,违反了 CoW”一次只改一个对象”的原则。3.0 里它会触发 ChainedAssignmentError,赋值不生效,明确告诉你别这么写。
正确姿势是用 loc 一步到位:
df.loc[df["bar"] > 5, "foo"] = 100
print(df)
loc 同时指定行条件和列名,一次索引、一次赋值,干净利落。凡是”先取子集再赋值”的需求,一律用 loc / iloc 单条语句完成。
Warning链式赋值在旧版 pandas 里”碰巧能跑”,只是偶尔失效并伴随警告。3.0 里赋值不会生效,并触发
ChainedAssignmentError。如果从旧代码迁移遇到它,不是环境坏了,是写法该升级了。
inplace 的另一个坑
类似的问题也出现在”对取出的列做 inplace 操作”上:
df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
df["foo"].replace(1, 5, inplace=True) # 想改 df,但 df 纹丝不动
print(df) # foo 还是 1, 2, 3
df["foo"] 产生一个临时对象,inplace 改的是它,df 不受影响。两种替代写法:
# 写法一:整体 replace,直接作用在 df 上
df.replace({"foo": {1: 5}}, inplace=True)
# 写法二:不用 inplace,把结果赋回去
df["foo"] = df["foo"].replace(1, 5)
什么没变:直接改 df 本身
别误会,CoW 不是禁止修改,而是禁止”通过派生对象间接修改”。直接修改对象本身完全正常:
df = pd.DataFrame({"foo": [1, 2, 3], "bar": [4, 5, 6]})
df.iloc[0, 0] = 100 # df 没和其他对象共享数据,原地修改
print(df)
df.loc[...] = 值、新增列 df["新列"] = [...]、删除列 df.drop(columns="x"),这些直接作用在 df 上的操作都不受影响。
CoW 约束的是这种场景:df2 = df.reset_index(drop=True) 之后,df2 和 df 共享数据。此时修改 df2 会先触发复制,df 保持原样——这正是我们想要的安全。
旧代码迁移到 3.0,重点检查两类写法:一是链式赋值,二是”先取出子集、之后又指望修改子集能传导回父对象”的代码。前者改 loc,后者改成直接对父对象操作。
显式触发复制:copy()
大多数情况下你不需要手动复制,CoW 已经保证了安全。但有两个场景需要显式调用 .copy():
- 你要独立修改一份数据,不希望和原对象有任何关联
- 你要把数据传给别的代码,担心它偷偷改你的对象
df.copy() 的复制语义和完整示例在 §10 详讲,这里只说一个相关变化:3.0 里 Series 和 DataFrame 的构造函数默认会复制传入的 NumPy 数组。想省掉这次复制,显式传 copy=False(§10 有完整对比)。
只读的 NumPy 数组
CoW 下还有一个值得知道的行为:to_numpy() 返回的数组可能是只读的。
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
arr = df.to_numpy()
# arr[0, 0] = 100 # 报错:ValueError: assignment destination is read-only
原因:arr 和 df 共享内存,直接改会破坏 CoW 规则,pandas 干脆把数组设为不可写。想改,两个办法:
arr = df.to_numpy().copy() # 办法一:先复制再改
arr2 = df.to_numpy()
arr2.flags.writeable = True # 办法二:强制可写(绕过 CoW,慎用)
arr2[0, 0] = 100
办法二性能更好,但绕过了 CoW 保护,改数组会连带改 df。只有确定不再需要 df 时才用。
CoW 的性能收益:懒复制
CoW 不只是安全机制,它还会偷懒:能推迟的复制就推迟。像 drop(axis=1)、rename 这类方法,以前会立即复制数据,现在只返回一个共享数据的”懒视图”,直到某个对象真的被修改才复制。
这带来一个使用建议:如果派生出来的对象用完就丢,直接赋值回同一个变量,让原对象尽快失去引用:
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
# 不推荐:df2 和 df 同时活着,改 df2 会触发一次复制
df2 = df.reset_index(drop=True)
# 推荐:覆盖原变量,旧对象失去引用,修改不触发复制
df = df.reset_index(drop=True)
反过来,保留过多共享数据的引用会拖慢性能。用完的中间变量,别舍不得丢。
小结
- CoW 是 3.0 的默认模式:派生对象表现得像副本,一次只改一个对象
- 链式赋值
df["foo"][mask] = v会触发ChainedAssignmentError,赋值不生效,改用df.loc[mask, "foo"] = v - 对列做 inplace 操作不会传导到 DataFrame,用整体操作或重新赋值
- 直接修改 df 本身(loc 赋值、增删列)不受 CoW 影响
- 需要真独立的数据用
.copy();构造函数的copy=False可以省一次复制 to_numpy()返回只读数组,先.copy()再改- 懒复制带来性能收益,用完的引用尽早释放
下一节把”视图 vs 副本”讲透,再看看 pandas 的底层存储和内存占用。