首页 / Pandas 入门教程 / 写时复制(Copy-on-Write)与赋值安全

Pandas 入门教程

写时复制(Copy-on-Write)与赋值安全

本教程共 54 篇 · 第 9 篇 · 更新于 2026-08-11 · 约 7 分钟阅读

PandasPandas 入门教程Copy-on-Write链式赋值赋值安全数据副本

本节目标:理解 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。原因:subsetdf 的一个视图(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) 之后,df2df 共享数据。此时修改 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

原因:arrdf 共享内存,直接改会破坏 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 的底层存储和内存占用。