Chapter 27, Advanced
Copy on write
"I changed here and it changed there" is the oldest bug class in Pandas. Pandas 3 eliminates it with a single rule, the copy-on-write, and understanding this rule explains the warnings you will see and the old patterns that stopped working.
The rule
Since Pandas 3, every object derived from another behaves like an independent copy. A selection, a column, a method result: changing any of them never alters the original. Underneath, Pandas does not copy the data immediately (it would be expensive): it shares and only copies at the moment someone writes. The visible rule is simple, and performance remains good.
import warnings
import numpy as np
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3], "b": [10.0, 20.0, 30.0]})
serie = df["a"]
serie.iloc[0] = 99
print(df["a"].tolist(), serie.tolist())
[1, 2, 3] [99, 2, 3]
The column was extracted, and the 99 was written only to it: the df remained as it was. Before Pandas 3, this writing could change the original table, or not, depending on internal details that no one could predict.
What the rule does not change: two names, one object
Copy-on-write is about derivedobjects. Assigning a table to another name does not create anything: they are two names for the same object (Python's label model), and writing through one of them appears in the other:
apelido = df
apelido.loc[0, "a"] = 5
print(df["a"].tolist(), apelido is df)
[5, 2, 3] True
Share until write
A "shallow" copy (deep=False) starts sharing memory with the original, and the first write separates them. You can see this happening:
raso = df.copy(deep=False)
print(np.shares_memory(df["a"].to_numpy(), raso["a"].to_numpy()))
raso.loc[1, "a"] = 77
print(np.shares_memory(df["a"].to_numpy(), raso["a"].to_numpy()), df["a"].tolist())
True
False [5, 2, 3]
Before writing, both share the memory (True), and no copy was made. After writing in raso, the memory separated (False), and the df continues with its values. That's why the .copy() defensive, which was written by reflection, is no longer necessary in most cases.
Chained assignment stopped working
The pattern df[condição]["coluna"] = valor alters an object intermediate (a temporary copy), and never the df. In Pandas 3, this generates a ChainedAssignmentError, and the table remains intact. The right way is a single operation .loc:
tabela = pd.DataFrame({"a": [1, 2, 3], "b": [10.0, 20.0, 30.0]})
with warnings.catch_warnings(record=True) as avisos:
warnings.simplefilter("always")
tabela[tabela["a"] > 1]["b"] = 0
print(tabela["b"].tolist(), [a.category.__name__ for a in avisos])
tabela.loc[tabela["a"] > 1, "b"] = 0
print(tabela["b"].tolist())
[10.0, 20.0, 30.0] ['ChainedAssignmentError']
[10.0, 0.0, 0.0]
The first command didn't change anything, and the warning says why. The second, with the .loc[linhas, coluna], wrote where it should.
`to_numpy()` returns a protected view
To prevent writing from leaking through a NumPy array, the to_numpy() returns, when possible, a read-only view. Want an array that you can change? Request a copy:
leitura = df["b"].to_numpy()
print(leitura.flags["WRITEABLE"])
try:
leitura[0] = 1
except ValueError as erro:
print("ValueError:", erro)
print(df["b"].to_numpy(copy=True).flags["WRITEABLE"])
False
ValueError: assignment destination is read-only
True
What the rule does not solve: functions that modify the argument
A function that writes to the table it received alters the caller's table , because both names are the same object. The copy under writing does not prevent this:The first function
def normalizar_mutando(tabela):
tabela.loc[:, "b"] = tabela["b"] / tabela["b"].max()
return tabela
def normalizar_sem_mutar(tabela):
return tabela.assign(b=tabela["b"] / tabela["b"].max())
original = df.copy()
resultado = normalizar_mutando(df)
print(resultado is df, df["b"].tolist() != original["b"].tolist())
df = original.copy()
novo = normalizar_sem_mutar(df)
print(novo is df, df["b"].tolist() == original["b"].tolist())
True True
False True
altered the of the caller df , and the values changed). The second returned a table (Trueand left the new intact. The rule I follow for analysis functions: df receive a table and return another receive a table and return another, with assign, rename and company, and they do not write in what they received.
| Situation | What happens in Pandas 3 |
|---|---|
s = df["a"] and then s[...] = x | Only changes s; the df does not change |
b = df and then b.loc[...] = x | Changes the df: it is the same object |
df[mascara]["col"] = x | Does not change the df, and warns (ChainedAssignmentError) |
df.loc[mascara, "col"] = x | Changes the df, as expected |
df.copy(deep=False) | Shares memory until the first write |
df["col"].to_numpy() | Read-only view (use copy=True to write) |
Exercise 1
A function that does not change what it receives
Write acrescentar_total(tabela), that returns a table new with the column total (sum of a and b), and check that the original table did not gain the column.