Skip to content

Fix: pandas SettingWithCopyWarning — A value is trying to be set on a copy

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix pandas SettingWithCopyWarning — understanding chained indexing, using .loc correctly, Copy-on-Write in pandas 2.x, and when the warning indicates a real bug vs a false alarm.

The Error

pandas raises a SettingWithCopyWarning when you try to modify a DataFrame:

/usr/local/lib/python3.11/site-packages/pandas/core/indexing.py:965: 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
  self._setitem_with_indexer(indexer, value)

Or a silent bug where your modification doesn’t affect the original DataFrame at all — no warning, just wrong results.

Why This Happens

pandas operations can return either a view (a reference to the original data) or a copy (a new DataFrame with duplicated data). The behavior depends on the operation and isn’t always predictable. This ambiguity is the root of SettingWithCopyWarning and the silent bugs it tries to prevent.

Chained indexing — two consecutive bracket operations — is the core issue:

df[df['age'] > 18]['salary'] = 50000
# Equivalent to:
temp = df[df['age'] > 18]  # Step 1: might return a copy
temp['salary'] = 50000      # Step 2: modifies the copy, not df

Step 1 may return a copy of the data. Modifying the copy in Step 2 doesn’t affect the original df. pandas warns you about this ambiguity.

The view-vs-copy decision is an implementation detail of pandas’ internal BlockManager. Single-dtype DataFrames (all columns are int64, for example) are more likely to return views because the underlying NumPy array is contiguous. Mixed-dtype DataFrames almost always return copies because each dtype block is stored separately. This means identical-looking code behaves differently depending on the column types, which is why the warning exists: pandas cannot guarantee consistent behavior across all DataFrames.

Other causes:

  • Slicing without .copy()subset = df[100:200] may return a view or copy depending on the context.
  • Boolean indexingdf[mask] typically returns a copy.
  • Column selectiondf[['col1', 'col2']] (list of columns) returns a copy; df['col1'] (single column) may return a view.

How Other Tools Handle This

The view-vs-copy ambiguity is specific to pandas’ mutable DataFrame model. Other data processing libraries avoid the problem entirely through different design choices, and understanding those designs clarifies why pandas made the trade-offs it did.

polars eliminates the problem by making DataFrames immutable by default. Every operation in polars returns a new DataFrame. There is no concept of a “view” into existing data. df.filter(pl.col("age") > 18).with_columns(pl.col("salary").fill_null(50000)) always produces a new DataFrame without modifying the original. This means there is no SettingWithCopyWarning equivalent, no .loc, and no ambiguity. The trade-off is that in-place modification is not supported. If you want to update a column, you reassign: df = df.with_columns(...). polars achieves performance despite the immutability through lazy evaluation and memory-mapped backing, avoiding unnecessary copies until the result is materialized.

DuckDB operates on a SQL model where tables are never modified by a SELECT query. SELECT *, salary * 1.1 AS adjusted FROM df WHERE age > 18 produces a new result set. DuckDB can query pandas DataFrames directly (duckdb.sql("SELECT * FROM df")) and returns results as pandas DataFrames or Arrow tables. Because SQL is inherently non-mutating for reads, there is no copy-vs-view issue. Updates use explicit UPDATE statements with clear semantics. For data exploration and transformation pipelines, replacing chained pandas operations with DuckDB SQL queries eliminates an entire class of bugs.

Spark DataFrames are immutable and distributed. Every transformation (df.filter(...), df.withColumn(...)) returns a new DataFrame. The execution is lazy: transformations are recorded as a plan and only executed when an action (collect(), show(), write()) is called. Because Spark DataFrames are immutable, there is no view-vs-copy distinction. The .withColumn() method always returns a new DataFrame with the column added or replaced. The trade-off is that Spark’s API is more verbose than pandas for simple operations, and the lazy execution model can produce confusing behavior when mixed with Python side effects.

R data.table takes the opposite approach from polars: it embraces mutation. The := operator modifies a data.table in place, and there is no ambiguity about whether the modification affects the original. dt[age > 18, salary := 50000] always modifies dt. When you want a copy, you explicitly call copy(dt). This is the inverse of pandas’ problem: R data.table users sometimes accidentally modify the original when they intended to work on a copy. The explicit := syntax makes the intent clear, which is what pandas’ .loc aims for but doesn’t achieve as cleanly because .loc looks syntactically similar to regular indexing.

pandas Copy-on-Write (CoW) in 2.0+ is pandas’ own solution to the problem. With CoW enabled, every indexing operation returns a lazy copy that shares memory with the original until one of them is modified. At the point of modification, pandas automatically copies the data. This eliminates SettingWithCopyWarning entirely because the behavior is deterministic: modifications to a subset never affect the original, and modifications to the original never affect subsets. CoW is available in pandas 2.0+ with pd.options.mode.copy_on_write = True and becomes the default in pandas 3.0. If you are starting a new project, enable CoW immediately. For existing projects, enable it and fix the resulting behavioral changes (primarily: code that relied on view semantics to modify the original through a subset will stop working and needs to switch to .loc).

Fix 1: Use .loc for Setting Values

Replace chained indexing with a single .loc operation. .loc always modifies in place on the original DataFrame:

# WRONG — chained indexing, triggers warning
df[df['age'] > 18]['salary'] = 50000

# CORRECT — single .loc operation
df.loc[df['age'] > 18, 'salary'] = 50000

More examples:

# WRONG
df[df['status'] == 'active']['score'] = df[df['status'] == 'active']['score'] * 1.1

# CORRECT
mask = df['status'] == 'active'
df.loc[mask, 'score'] = df.loc[mask, 'score'] * 1.1

Setting a single cell:

# WRONG — chained indexing
df[df['id'] == 42]['name'] = 'Alice'

# CORRECT
df.loc[df['id'] == 42, 'name'] = 'Alice'

# Or by row position and column position
df.iloc[5, df.columns.get_loc('name')] = 'Alice'

Why .loc works: .loc[row_indexer, col_indexer] is a single indexing operation on the original DataFrame. There’s no intermediate copy. pandas knows unambiguously that you want to modify df itself.

Fix 2: Call .copy() When You Intend to Work on a Subset

When you genuinely want a separate copy to modify without affecting the original, be explicit:

# Without .copy() — might be a view, triggers warning when you modify it
young_users = df[df['age'] < 25]
young_users['discount'] = 0.2  # Warning — are we modifying df or a copy?

# With .copy() — explicit copy, no ambiguity, no warning
young_users = df[df['age'] < 25].copy()
young_users['discount'] = 0.2  # No warning — working on a known copy

Use .copy() when:

  • You’re creating a subset to work with independently
  • You want to add/modify columns without affecting the original
  • You’re passing a subset to a function that modifies it

Don’t use .copy() when:

  • You want your modifications to reflect back to the original (use .loc instead)

Common Mistake: Using .copy() inside a loop that iterates over groups creates a copy per group. For large DataFrames, this can exhaust memory. If you are processing groups, use groupby().apply() or groupby().transform() instead of iterating and copying.

Fix 3: Understand pandas 2.0 Copy-on-Write

pandas 2.0 introduced Copy-on-Write (CoW), which changes the behavior significantly. In pandas 2.x with CoW enabled, indexing operations always return copies — but they’re lazy copies (only duplicated when modified). This eliminates the ambiguity:

# Enable CoW in pandas 1.5+ (enabled by default in pandas 3.0)
import pandas as pd
pd.options.mode.copy_on_write = True

df = pd.DataFrame({'age': [20, 30, 40], 'salary': [50000, 60000, 70000]})

# With CoW, this no longer triggers a warning:
subset = df[df['age'] > 25]
subset['salary'] = 99999  # Modifies the copy, not df — clear and unambiguous

print(df['salary'])  # [50000, 60000, 70000] — df unchanged

CoW changes the semantics: modifications to subsets never affect the original. If you want to modify the original, you must use .loc explicitly:

# With CoW — modify the original using .loc
df.loc[df['age'] > 25, 'salary'] = 99999  # Required to affect df

Check your pandas version:

import pandas as pd
print(pd.__version__)
# 2.0+ has CoW available
# 3.0+ CoW is the default

Fix 4: Use .assign() for Method Chaining

.assign() always returns a new DataFrame with the modification applied, making chaining safe and explicit:

# Instead of modifying in place with chained indexing
df_active = df[df['status'] == 'active']
df_active['score'] = df_active['score'] * 1.1  # Warning

# Use .assign() — returns a new DataFrame, no mutation
df_active = (
    df[df['status'] == 'active']
    .assign(score=lambda x: x['score'] * 1.1)
    .assign(level=lambda x: pd.cut(x['score'], bins=[0, 50, 100], labels=['low', 'high']))
)

.assign() is particularly clean for data transformation pipelines where you’re building a new DataFrame rather than modifying an existing one.

Fix 5: Silence or Upgrade the Warning Appropriately

If the warning is a false positive (you’ve verified your code is correct), suppress it for a specific block:

import pandas as pd
import warnings

# Suppress for a specific operation you've verified is correct
with warnings.catch_warnings():
    warnings.simplefilter("ignore", pd.errors.SettingWithCopyWarning)
    df['new_col'] = 'value'  # You know this is safe

Globally disable the warning (use sparingly — you may miss real bugs):

pd.options.mode.chained_assignment = None  # Suppress completely
# or
pd.options.mode.chained_assignment = 'warn'  # Default — show warning
# or
pd.options.mode.chained_assignment = 'raise'  # Convert to error (strictest)

Warning: Setting chained_assignment = None globally hides warnings that might indicate real bugs — modifications that look like they work but silently fail. Use this only when you’ve explicitly verified the code is correct and want to reduce noise.

Prefer upgrading the code over silencing. The warning exists because chained assignment is genuinely ambiguous and error-prone. Fix the code with .loc or .copy() rather than suppressing the warning.

Fix 6: Diagnose Whether You Have a Real Bug

The warning doesn’t always mean your code is wrong — sometimes it’s a false positive. Check whether your intended modification actually took effect:

df = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]})

# This might or might not work — depends on whether df[...] returned a view or copy
df[df['a'] > 1]['b'] = 99

# Verify if df actually changed
print(df)
#    a  b
# 0  1  4
# 1  2  5  ← Still 5, not 99? You have a real bug — use .loc
# 2  3  6

If df didn’t change, you were modifying a copy — that’s a bug. Use .loc:

df.loc[df['a'] > 1, 'b'] = 99
print(df)
#    a   b
# 0  1   4
# 1  2  99
# 2  3  99

Common Patterns and Their Fixes

# Pattern 1: Modify a filtered subset
# WRONG
df[df['country'] == 'US']['tax_rate'] = 0.25
# CORRECT
df.loc[df['country'] == 'US', 'tax_rate'] = 0.25

# Pattern 2: Work on a subset independently
# WRONG (ambiguous)
us_users = df[df['country'] == 'US']
us_users['category'] = 'domestic'  # Warning
# CORRECT (explicit copy)
us_users = df[df['country'] == 'US'].copy()
us_users['category'] = 'domestic'  # No warning

# Pattern 3: Apply transformation to a column in filtered rows
# WRONG
df[df['score'] > 0]['normalized'] = df[df['score'] > 0]['score'] / 100
# CORRECT
mask = df['score'] > 0
df.loc[mask, 'normalized'] = df.loc[mask, 'score'] / 100

# Pattern 4: In a function that receives a DataFrame slice
def process_subset(subset):
    # WRONG — modifies 'subset', but caller may get a copy
    subset['processed'] = True
    # CORRECT — work on an explicit copy
    subset = subset.copy()
    subset['processed'] = True
    return subset

# Pattern 5: Apply row by row (avoid when possible — slow)
# If you must use apply, return a new Series rather than mutating
df['new_col'] = df.apply(lambda row: compute(row['a'], row['b']), axis=1)

Still Not Working?

Enable strict mode to catch bugs earlier:

pd.options.mode.chained_assignment = 'raise'
# SettingWithCopyWarning becomes an exception — easy to find the exact line

Inspect whether an object is a view or copy:

# Check if two DataFrames share memory
import numpy as np

print(np.shares_memory(df['col'], subset['col']))
# True = view (shared memory)
# False = copy (independent memory)

Check pandas documentation for your operation. The pandas docs include a table of which operations return views vs copies. The rules changed in pandas 1.x and again in 2.x.

Check if the warning comes from inside a library you are calling:

import warnings
import traceback

# Show the full traceback for SettingWithCopyWarning to find the exact source
warnings.filterwarnings('error', category=pd.errors.SettingWithCopyWarning)
try:
    # your code here
    pass
except Warning as w:
    traceback.print_exc()

If the warning originates inside a library (e.g., sklearn, seaborn, or a data processing pipeline), the fix must happen in that library’s code or by updating to a version that uses .loc or .copy() correctly. Filing an issue is appropriate.

Verify that .loc is actually a single operation and not split across lines:

# This is STILL chained indexing even though it uses .loc on the second step
temp = df[df['status'] == 'active']
temp.loc[:, 'score'] = 99  # Warning — temp may be a copy of df

# This is a single operation — no chaining
df.loc[df['status'] == 'active', 'score'] = 99  # Correct

The difference is whether the filtering and the assignment happen in one .loc call or two separate steps. Two steps means two operations, and the first might return a copy.

For related pandas issues, see Fix: pandas merge KeyError, Fix: Python TypeError Unhashable Type List, Fix: polars Not Working, and Fix: DuckDB Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles