Fix pandas 3.0 "ChainedAssignmentError": SettingWithCopyWarning Is Gone

Fix pandas 3.0 "ChainedAssignmentError": SettingWithCopyWarning Is Gone

Copy-on-Write was an opt-in preview in pandas 2.0. In pandas 3.0 it's the only mode there is. That single change removes SettingWithCopyWarning from the library entirely and replaces the whole class of chained-assignment bugs it used to flag with a different warning, ChainedAssignmentError, that behaves differently in a way that matters. This article reproduces the new behavior against a real pandas 3.0.3 install, shows what stops working on upgrade, and covers the fix.

Reproducing It: Chained Assignment Now Warns and No-Ops

The classic chained-assignment pattern — filter first, then assign into the filtered result — still runs without raising in pandas 3.0.3. But look at what happens to the data:

import pandas as pd
print(pd.__version__)   # 3.0.3

df = pd.DataFrame({'a': [1, 2, 3], 'b': [10, 20, 30]})
df[df.a > 1]['b'] = 999
# ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series
# through chained assignment. Such chained assignment never works to update the
# original DataFrame or Series, because the intermediate object on which we are
# setting values always behaves as a copy (due to Copy-on-Write).

print(df)
#    a   b
# 0  1  10
# 1  2  20
# 2  3  30    <-- unchanged. The assignment never happened.

df[df.a > 1] creates a new, temporary DataFrame under Copy-on-Write — not a view into df. Setting ['b'] = 999 on that temporary object modifies the temporary object, which is then discarded, and df itself is never touched. pandas detects this specific pattern and emits ChainedAssignmentError to say so — but by default it's a warning, not a raised exception, so the line above completes normally and execution continues straight past it.

The Other Common Pattern: Column-Then-Row Indexing

The same warning fires for the equally common reverse order — select a column first, then index into a row of it:

df = pd.DataFrame({'a': [1, 2, 3], 'b': [10, 20, 30]})
df['b'][0] = 999
# ChainedAssignmentError: (same message)

print(df)
#    a   b
# 0  1  10   <-- still 10, not 999
# 1  2  20
# 2  3  30

Same root cause: df['b'] returns a Series that Copy-on-Write treats as independent from df once you write into it via a second index step. Whether the filter comes first or the column selection comes first, any two-step indexed assignment on a DataFrame hits the same warning and the same silent no-op.

What's Actually Gone: SettingWithCopyWarning and the CoW Toggle

Two settings that worked as escape hatches through pandas 2.x are removed as of 3.0.3, confirmed by testing both directly:

import pandas as pd

# 1. The old warning class no longer exists at all
pd.errors.SettingWithCopyWarning
# AttributeError: module 'pandas.errors' has no attribute 'SettingWithCopyWarning'

# 2. Copy-on-Write can no longer be disabled -- the option is a no-op
pd.options.mode.copy_on_write = False
# Pandas4Warning: The 'mode.copy_on_write' option is deprecated. Copy-on-Write can
# no longer be disabled (it is always enabled with pandas >= 3.0), and setting the
# option has no impact. This option will be removed in pandas 4.0.

print(pd.options.mode.copy_on_write)   # False -- the flag itself still "accepts" the value...
# ...but every write still behaves as if CoW is on, because it always is.

Two upgrade cases follow from this. A codebase that catches pd.errors.SettingWithCopyWarning by name, in a filter or a try/except block, will get AttributeError the moment that code path runs, since the class no longer exists to catch. A codebase that instead relied on pd.set_option('mode.chained_assignment', 'raise') to fail tests on chained assignment gets a quieter problem: the call still executes without complaint, but it no longer turns anything into a raised exception. Tests written around that setting can keep passing while the underlying assignment silently does nothing.

The practical trap: only one of these two failure modes is loud. Referencing the removed warning class raises AttributeError right away. The CoW toggle does not raise; it just accepts the assignment and changes nothing, with only a deprecation warning as evidence anything is off.

The Fix: .loc in a Single Step

pandas has recommended this fix since SettingWithCopyWarning first existed. Under mandatory Copy-on-Write it stops being a recommendation and becomes the only indexing pattern that actually writes to the original DataFrame. Do the filter and the assignment in one indexing call instead of two:

df = pd.DataFrame({'a': [1, 2, 3], 'b': [10, 20, 30]})
df.loc[df.a > 1, 'b'] = 999
print(df)
#    a    b
# 0  1   10
# 1  2  999
# 2  3  999    <-- actually updated

.loc[row_indexer, col_indexer] = value passes both the row selection and the column selection into a single call, so pandas can resolve the assignment directly against the original DataFrame instead of constructing an intermediate object first. Same fix applies to the column-then-row pattern:

df = pd.DataFrame({'a': [1, 2, 3], 'b': [10, 20, 30]})
df.loc[0, 'b'] = 999   # instead of df['b'][0] = 999
print(df.loc[0, 'b'])   # 999

Why This Is a Harder Bug to Notice Than the Old Warning

SettingWithCopyWarning, back when it existed, fired on code that sometimes worked and sometimes didn't, depending on the DataFrame's internal memory layout at that moment. Confusing, but the assignment frequently still went through underneath the warning, so the bug was inconsistent rather than guaranteed. Under pandas 3.0's mandatory Copy-on-Write, the identical line of code runs the same way every time: the original data is never modified. In a script with warnings filtered, or a notebook where a warning scrolls past unread, nothing visibly interrupts execution. Downstream code keeps running against a DataFrame that looks assigned to but was never actually written, and the wrong result it eventually produces can be several steps removed from the line that caused it, which is what makes this one worth checking for directly rather than assuming a clean run means the assignment happened.

Summary

Situation                                          Fix
─────────────────────────────────────────────    ─────────────────────────────────────────
df[mask]['col'] = value                            df.loc[mask, 'col'] = value
df['col'][idx] = value                              df.loc[idx, 'col'] = value
Catching pd.errors.SettingWithCopyWarning           Remove it -- the class no longer exists,
  anywhere in your own code                           referencing it raises AttributeError
Relying on mode.chained_assignment='raise' or        Neither works anymore -- CoW is
  copy_on_write=False to enforce/disable this          mandatory and can't be toggled
Not sure if a chained assignment silently no-oped   Re-run with warnings as errors
                                                       (warnings.simplefilter('error')) to
                                                       surface every ChainedAssignmentError
                                                       as a real exception during testing