Fix pandas SettingWithCopyWarning: Chained Assignment and .loc Solutions
- To change the original DataFrame, do it in one step:
df.loc[mask, "col"] = value. It worked with no warning on 2.2, 2.2 with CoW, and 3.0. - To get an independent subset you will modify, write
sub = df[mask].copy()(or build new columns withdf[mask].assign(new=...)). The original stays unchanged on every version. - Replace inplace calls on a column (
df["col"].fillna(0, inplace=True)) with reassignment:df["col"] = df["col"].fillna(0). - Do not silence it.
chained_assignment = Nonehides the 2.2 warning but not the bug, and it has no effect on the pandas 3.0 warning.
SettingWithCopyWarning no longer exists; chained assignment emits ChainedAssignmentError (a warning, despite the name) and never updates the original. Some chained code that silently worked on 2.2, such as df["col"][mask] = v, stops working on 3.0.
SettingWithCopyWarning shows up after an assignment that looks fine,
and it does not stop the script. What it is telling you is that the value may have
been written to a temporary copy and your DataFrame may be unchanged. Below, each
common pattern was run on pandas 2.2 and 3.0 and the original DataFrame checked
afterwards, because since pandas 3.0 (Copy-on-Write by default) the answer differs
by version.
What the warning actually says
When you run code like the following:
import pandas as pd
df = pd.DataFrame({
"city": ["Madrid", "Berlin", "Tokyo", "Paris"],
"temperature": [28, 15, 32, 21],
"country": ["Spain", "Germany", "Japan", "France"],
})
# Chained indexing: common mistake
df[df["temperature"] > 20]["temperature"] = 99
pandas 2.2.3 prints this (copied from the run):
SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead
See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
The same line on pandas 3.0.6 prints a different warning class and message:
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).
Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.
On both versions, df["temperature"] was still [28, 15, 32, 21]
afterwards. If you searched for the old warning text but see the new one, you are
on pandas 3.0; the fix is the same.
The key phrase is "on a copy of a slice". pandas detected two indexing operations chained together, and the assignment may have landed on a temporary object that got discarded immediately after, not your original DataFrame. No exception is raised and the values you meant to set are not there.
Why chained indexing creates a copy in the first place
Pandas indexing can return either a view (a reference into the same underlying NumPy array) or a copy (a brand-new array with duplicated data). When you chain two indexing operations, the first one may already produce a copy, so the second one, the assignment, writes into that copy, not the original.
# This line is actually two operations:
df[df["temperature"] > 20]["temperature"] = 99
# pandas sees it as:
temp = df[df["temperature"] > 20] # Operation 1: might return a copy
temp["temperature"] = 99 # Operation 2: writes to temp, not df
The internal rules for when pandas returns a view versus a copy are complex and not part of the public API contract. Fancy indexing (boolean masks, lists of labels) almost always returns a copy. Basic integer or label slicing sometimes returns a view. You cannot reliably predict which path will be taken, so you should never write code that depends on either outcome.
Inspect df after that chained assignment and the temperature values
are exactly what they were before. The script keeps running on the old values.
Reproduced: pandas 2.2 vs 3.0
Each pattern below ran on a fresh copy of the four-city DataFrame above
(temperatures [28, 15, 32, 21]), with all warnings recorded.
"Changed" means df itself was different afterwards.
| Pattern | pandas 2.2.3 (default) | pandas 2.2.3, copy_on_write=True | pandas 3.0.6 |
|---|---|---|---|
df[mask]["col"] = 99 | SettingWithCopyWarning; unchanged | ChainedAssignmentError; unchanged | ChainedAssignmentError; unchanged |
df["col"][mask] = 99 | SettingWithCopyWarning + FutureWarning; changed to [99, 15, 99, 99] | ChainedAssignmentError; unchanged | ChainedAssignmentError; unchanged |
sub = df[mask]; sub["new"] = "hot" | SettingWithCopyWarning; unchanged | no warning; unchanged | no warning; unchanged |
sub = df[mask]; sub.loc[:, "col"] = 99 | no warning; unchanged | no warning; unchanged | no warning; unchanged |
top = df[:2]; top["col"] = 0 | SettingWithCopyWarning; unchanged | no warning; unchanged | no warning; unchanged |
s = df["col"]; s[0] = -1 | SettingWithCopyWarning; changed to [-1, 15, 32, 21] | no warning; unchanged | no warning; unchanged |
df["col"].replace(28, 100, inplace=True) | FutureWarning; changed | ChainedAssignmentError; unchanged | ChainedAssignmentError; unchanged |
df.loc[mask, "col"] = 99 | no warning; changed to [99, 15, 99, 99] | same | same |
df[mask].copy() then add a column | no warning; unchanged | same | same |
df[mask].assign(label="hot") | no warning; unchanged | same | same |
Three things in that table contradict advice you will still find in older answers:
- "It's only a warning, the assignment still works." Sometimes true on 2.2 (rows 2, 6 and 7 did change
df), which is exactly why that code breaks on upgrade. On 3.0 none of them changedf. - "Just use .loc." Only
.locon the original DataFrame helps..locon a filtered subset (row 4) updated the subset, leftdfalone, and printed no warning at all on 2.2.3, so nothing tells you it did not do what you wanted. - "Copy-on-Write makes the bug visible." Only for one-line chains (rows 1, 2, 7). Once the subset has a name (rows 3 to 6), CoW is silent: the subset changes,
dfdoes not, and nothing is printed. That is correct behavior, not an error, but it is not a warning you can rely on to catch a mistake.
The FutureWarning in row 2 on 2.2.3 reads ChainedAssignmentError: behaviour will
change in pandas 3.0!; in row 7 it begins A value is trying to be set on
a copy of a DataFrame or Series through chained assignment using an inplace
method. If you see either on 2.2, that line will stop updating your data
after upgrading.
The fix that covers most cases: .loc[]
The fix for most SettingWithCopyWarning
cases is to collapse two indexing operations into a single .loc[] call.
df.loc[...] = value is a single __setitem__ call on
df itself, so there is no intermediate object to lose the write.
The printed output is from the actual run, identical on pandas 2.2.3 and 3.0.6.
import pandas as pd
df = pd.DataFrame({
"city": ["Madrid", "Berlin", "Tokyo", "Paris"],
"temperature": [28, 15, 32, 21],
"country": ["Spain", "Germany", "Japan", "France"],
})
# BEFORE (SettingWithCopyWarning on 2.2, ChainedAssignmentError on 3.0, df unchanged):
df[df["temperature"] > 20]["temperature"] = 99
# AFTER (correct, no warning on 2.2 or 3.0):
df.loc[df["temperature"] > 20, "temperature"] = 99
print(df)
# city temperature country
# 0 Madrid 99 Spain
# 1 Berlin 15 Germany
# 2 Tokyo 99 Japan
# 3 Paris 99 France
The syntax is df.loc[row_selector, column_name]. The row selector can
be a boolean Series, a list of labels, a slice, or a scalar label. The column name
is a single label or a list of labels.
More .loc[] patterns
# Set multiple columns at once
df.loc[df["temperature"] > 20, ["temperature", "country"]] = [99, "Hot"]
# Conditional update using another column's value
df.loc[df["country"] == "Japan", "temperature"] = df.loc[df["country"] == "Japan", "temperature"] * 1.1
# Reset a value to NaN
import numpy as np
df.loc[df["city"] == "Berlin", "temperature"] = np.nan
# Using index positions instead of labels: use .iloc for that
df.iloc[0, 1] = 30 # row 0, column index 1
When you actually want an independent subset: .copy()
Sometimes you deliberately want to extract a subset of a DataFrame and modify it
independently, without affecting the original. In that case, the fix is to call
.copy() explicitly when creating the subset.
import pandas as pd
df = pd.DataFrame({
"city": ["Madrid", "Berlin", "Tokyo", "Paris"],
"temperature": [28, 15, 32, 21],
"country": ["Spain", "Germany", "Japan", "France"],
})
# AMBIGUOUS: SettingWithCopyWarning on 2.2, silent on 3.0. df is unchanged on both,
# but a reader cannot tell whether you meant to modify df or the subset.
hot_cities = df[df["temperature"] > 20]
hot_cities["label"] = "hot"
# CORRECT: explicitly copy so you own the data:
hot_cities = df[df["temperature"] > 20].copy()
hot_cities["label"] = "hot" # No warning, works as expected
print(hot_cities)
# city temperature country label
# 0 Madrid 28 Spain hot
# 2 Tokyo 32 Japan hot
# 3 Paris 21 France hot
# Same result without mutating anything:
hot_cities = df[df["temperature"] > 20].assign(label="hot")
Use .copy() whenever your intent is to produce a derived DataFrame
that has its own independent data. This is the correct pattern for feature
engineering pipelines where you filter rows, add computed columns, and pass the
result to a model, all without touching the original DataFrame.
.copy(). If you will write to the subset (add or
modify columns/values), always call .copy() to make your intent
explicit and avoid the warning.
The .str and .apply() version of the same mistake
The .str accessor and .apply() are common sources of
chained assignment because they return a new Series, and assigning back to a
column on a slice triggers the warning.
import pandas as pd
df = pd.DataFrame({
"name": [" Alice ", "bob", "CHARLIE"],
"score": [88, 72, 95],
})
# WRONG if you meant to update df: warns on 2.2, silent on 3.0, df unchanged on both
subset = df[df["score"] > 80]
subset["name"] = subset["name"].str.strip().str.title()
# CORRECT: use .loc[] with the mask and column name:
mask = df["score"] > 80
df.loc[mask, "name"] = df.loc[mask, "name"].str.strip().str.title()
print(df)
# name score
# 0 Alice 88
# 1 bob 72
# 2 Charlie 95
# apply() follows the same rule
import pandas as pd
df = pd.DataFrame({"value": [1, 2, 3, 4, 5]})
# WRONG: assigns to a filtered copy (ChainedAssignmentError on 3.0, df unchanged):
df[df["value"] > 2]["value"] = df[df["value"] > 2]["value"].apply(lambda x: x ** 2)
# CORRECT:
mask = df["value"] > 2
df.loc[mask, "value"] = df.loc[mask, "value"].apply(lambda x: x ** 2)
print(df)
# value
# 0 1
# 1 2
# 2 9
# 3 16
# 4 25
Both examples share the same shape: build the boolean mask as its own variable
first, then reuse it inside .loc[mask, col] on both sides of the
assignment. Once you've written it this way a few times it stops feeling like a
workaround and just becomes how you filter-and-assign.
pandas 3.0: Copy-on-Write is the default
pandas 2.0 introduced Copy-on-Write (CoW) as an opt-in mode, and
pandas 3.0 made it the only mode. Under CoW, any object derived from another
(a filtered frame, a column pulled out as a Series, a slice) behaves as a copy.
Data is shared lazily and only duplicated when one side is written to. The
view-versus-copy question that SettingWithCopyWarning existed to warn
about no longer has two answers, so pandas 3.0 removed the warning class
(pandas.errors.SettingWithCopyWarning does not exist on 3.0.6).
import pandas as pd # pandas 3.0.6
df = pd.DataFrame({
"city": ["Madrid", "Berlin", "Tokyo", "Paris"],
"temperature": [28, 15, 32, 21],
})
# Named subset: no warning, and df is not modified
subset = df[df["temperature"] > 20]
subset["temperature"] = 99
print(df["temperature"].tolist()) # [28, 15, 32, 21]
# One-line chain: ChainedAssignmentError warning, df still not modified
df[df["temperature"] > 20]["temperature"] = 99
print(df["temperature"].tolist()) # [28, 15, 32, 21]
# The correct pattern is unchanged:
df.loc[df["temperature"] > 20, "temperature"] = 99
print(df["temperature"].tolist()) # [99, 15, 99, 99]
ChainedAssignmentError is a Warning subclass, so despite
the name it does not stop your script. To make it fail, call
warnings.simplefilter("error", pd.errors.ChainedAssignmentError) after
importing pandas; on 3.0.6 that turned the chained line into a raised exception. In
our run, the command-line form python -W error::pandas.errors.ChainedAssignmentError
did not raise, so set the filter in code (or in your test config).
Preparing a pandas 2.2 codebase for 3.0
On 2.2 you can opt in with pd.options.mode.copy_on_write = True, or
set the environment variable PANDAS_COPY_ON_WRITE=1 before starting
Python (confirmed on 2.2.3: the option reads True with the variable
set). Your code then behaves like 3.0 for every row in the table above.
For a migration, pd.options.mode.copy_on_write = "warn" on 2.2 is
more useful than turning CoW fully on: it keeps the old behavior but warns
where the result will differ. On 2.2.3, s = df["temperature"]; s[0] = -1
in warn mode still changed df and printed
FutureWarning: Setting a value on a view: behaviour will change in pandas 3.0.
That is row 6 of the table, the case plain CoW handles silently.
On pandas 3.0, setting pd.options.mode.copy_on_write does nothing and
emits Pandas4Warning: The 'mode.copy_on_write' option is deprecated.
Copy-on-Write can no longer be disabled. Remove it once you have upgraded.
Silencing the warning, and why it rarely helps
On pandas 2.x you can suppress the warning globally by setting the chained
assignment option to None:
import pandas as pd
pd.options.mode.chained_assignment = None # pandas 2.x: suppress SettingWithCopyWarning
None, df[mask]["temperature"] = 99 printed nothing and
df was still unchanged. On 3.0.6 the option still exists but has no
effect on chained assignment: both None and "raise" still
produced the ChainedAssignmentError warning in our run. Code that
imports SettingWithCopyWarning to filter it, such as
warnings.simplefilter("ignore", pd.errors.SettingWithCopyWarning),
fails on 3.0 because the class is gone.
The one case where suppression is reasonable is a phased migration on a legacy
2.x codebase, thousands of lines of chained indexing that you are replacing with
.loc[] over several sprints, where silencing cuts noise while the work
is in progress. Be aware that some of that legacy code (rows 2, 6 and 7 of the
table) does modify df on 2.2 and will stop doing so on 3.0, so
suppression also hides the exact lines that will break on upgrade. Read-only
chained access (val = df[df["x"] > 0]["y"].mean()) is harmless and
printed no warning on either version.
If you must suppress on 2.x, scope it with option_context instead of
setting it globally:
import pandas as pd
# On 2.2.3 this filled the NaN (and still printed a FutureWarning despite None).
# On 3.0.6 it left the NaN in place and printed ChainedAssignmentError.
with pd.option_context("mode.chained_assignment", None):
df["col"][df["col"].isna()] = 0 # legacy code you haven't refactored yet
# The version that works everywhere:
df.loc[df["col"].isna(), "col"] = 0
Never suppress the warning at module level in library code or in shared notebooks, because downstream users will inherit the suppression without knowing it. Fix the indexing instead.
Quick reference: chained assignment options
import pandas as pd
# pandas 2.x: default, emits SettingWithCopyWarning
pd.options.mode.chained_assignment = "warn"
# pandas 2.x: raise instead (on 2.2.3 this raised pandas.errors.SettingWithCopyError)
pd.options.mode.chained_assignment = "raise"
# pandas 2.x: suppress the warning (the bug stays)
pd.options.mode.chained_assignment = None
# pandas 2.2: opt in to 3.0 behavior, or "warn" to flag lines that will change
pd.options.mode.copy_on_write = True # or "warn"
# pandas 3.0: none of the above matter; CoW is always on.
# Turn the ChainedAssignmentError warning into a failure in CI instead:
import warnings
warnings.simplefilter("error", pd.errors.ChainedAssignmentError)
On 2.x, chained_assignment = "raise" is a useful CI strategy: it turns
the warning into a hard exception so that new chained assignment code cannot be
merged without being caught by tests. On 3.0, the simplefilter line above
does the same job.