Skip to content

    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.

    avancado/cap27_copy_on_write.pylinhas 10 a 19
    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())
    
    Output
    [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:

    avancado/cap27_copy_on_write.pylinhas 24 a 26
    apelido = df
    apelido.loc[0, "a"] = 5
    print(df["a"].tolist(), apelido is df)
    
    Output
    [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:

    avancado/cap27_copy_on_write.pylinhas 31 a 34
    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())
    
    Output
    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:

    avancado/cap27_copy_on_write.pylinhas 39 a 46
    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())
    
    Output
    [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:

    avancado/cap27_copy_on_write.pylinhas 51 a 57
    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"])
    
    Output
    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

    avancado/cap27_copy_on_write.pylinhas 62 a 77
    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())
    
    Output
    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.

    SituationWhat happens in Pandas 3
    s = df["a"] and then s[...] = xOnly changes s; the df does not change
    b = df and then b.loc[...] = xChanges the df: it is the same object
    df[mascara]["col"] = xDoes not change the df, and warns (ChainedAssignmentError)
    df.loc[mascara, "col"] = xChanges 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.