Copy-on-write semantics

pandas · Advanced · 15 min

CoW removes SettingWithCopyWarning ambiguity and silent sharing bugs, but changes behaviour older code relied on.

Historically, whether a pandas operation returned a view (sharing data with the original) or a copy depended on internals. Modifying a result might or might not change the parent, and SettingWithCopyWarning tried, imperfectly, to warn you.

Copy-on-write (CoW) replaces that with one simple rule: every DataFrame or Series derived from another behaves as a copy. Modifying it never changes the parent, and modifying the parent never changes it. CoW is the only mode in pandas 3.0; in pandas 2.2 enable it with pd.options.mode.copy_on_write = True, or set it to "warn" to find code whose behaviour will change.

It's also faster. Internally, derived objects still share memory as lazy views. A real copy happens only when one of them is written to, and only for the data being modified. So operations like reset_index, rename, set_index or selecting columns no longer copy eagerly, and defensive .copy() calls become unnecessary.

Consequences for your code:

  • Chained assignment never works: df["a"][mask] = 0 or df[df.x > 0]["y"] = 1 modify a temporary object and leave df unchanged; pandas emits a ChainedAssignmentError warning. Write df.loc[mask, "a"] = 0.
  • Updating a column extracted earlier (s = df["a"]; s.iloc[0] = 99) no longer changes df.
  • inplace=True on a column selection, like df["a"].fillna(0, inplace=True), doesn't touch df. Use df["a"] = df["a"].fillna(0).
  • df.to_numpy() and s.values may return read-only arrays when they share memory with the frame; call .copy() on the array to write to it.

The mental model becomes simple: to change a DataFrame, assign to it directly with df[...] = or df.loc[...] =. Everything else produces a new object.

Example

import pandas as pd

if pd.__version__.startswith("2."):
    pd.options.mode.copy_on_write = True # opt in on 2.x; always on in 3.0
df = pd.DataFrame({"x": [1, -2, 3], "y": [0, 0, 0]})

subset = df[df["x"] > 0]
subset.loc[:, "y"] = 1                   # modifies subset only
print(df["y"].tolist())                  # [0, 0, 0]

col = df["x"]
col.iloc[0] = 100                        # copy happens lazily here
print(df["x"].tolist())                  # [1, -2, 3]

df[df["x"] > 0]["y"] = 5                 # chained: warns, has no effect
print(df["y"].tolist())                  # [0, 0, 0]

df.loc[df["x"] > 0, "y"] = 5             # the correct way
print(df["y"].tolist())                  # [5, 0, 5]

arr = df["x"].to_numpy()
print(arr.flags.writeable)               # False: shares memory with df

Remember

  • Under CoW every derived object behaves as an independent copy
  • Copies are lazy: memory is shared until something is written
  • Chained assignment never updates the original; use one .loc[...] =
  • Default in pandas 3.0; opt in on 2.2 with pd.options.mode.copy_on_write

Watch out for

  • Calling df['col'].replace(..., inplace=True) and expecting df to change under CoW.

Check yourself

  1. Under copy-on-write, `s = df['a']; s[0] = 99`. What happens to `df`?
  2. Why is `df.reset_index()` cheaper under CoW than before?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: pandas.pydata.org/docs/user_guide/copy_on_write.html