Two brackets, two objects. The assignment lands on the second one, and the second one is temporary.
import pandas as pd
frame = pd.DataFrame({'a': [1, 2, 3], 'b': [10, 20, 30]})
frame[frame.a > 1] # a NEW frame holding the matching rows
frame[frame.a > 1]['b'] = 0 # writes into that new frame, then drops it
frame.b.tolist() # -> [10, 20, 30] nothing happened
# pandas emits a SettingWithCopyWarning and carries on
# One indexing operation instead of two, so there is no temporary:
frame.loc[frame.a > 1, 'b'] = 0
frame.b.tolist() # -> [10, 0, 0]
- Two brackets means two operations, and the first one copies
frame[mask] is a selection — it returns a new frame, because the matching rows are scattered and cannot be a window onto the original. The second bracket then assigns into that new frame. Both steps succeed; the result is simply thrown away at the end of the statement, because nothing holds a reference to it.
- .loc[rows, column] is one operation
Given both the rows and the column at once, pandas can write straight into the original — there is no intermediate to lose. That is the whole fix, and the rule that follows from it is worth making a habit: if an assignment has two sets of square brackets on the left of the equals sign, it is probably not doing what it looks like.
- A warning is not an error, which is why this reaches production
pandas raises SettingWithCopyWarning, and warnings are printed once, swallowed by log configuration, filtered by test runners and invisible in a notebook cell that produced output. It also cannot always tell — the check is a heuristic, so it sometimes warns when the code is fine and occasionally stays quiet when it is not. Treat the shape of the code as the signal rather than the warning.
Count the square brackets on the left of the assignment. Two separate indexing operations means the first one produced something the second one wrote into and nobody kept.
Say which rows and which column in one operation, with .loc[rows, column], so there is no intermediate frame to lose. The .copy() at the top stays — that is what protects the caller.
result.loc[mask, 'price'] *= discount reads the selection and writes back through the same indexer. Do not reach for a second .copy() or for reset_index: the index and the column order are part of what the contract promises to preserve.
import pandas as pd def apply_discount(frame, threshold, discount): result = frame.copy() qualifies = result['quantity'] >= threshold result.____[qualifies, '____'] *= discount return result
import pandas as pd
def apply_discount(frame, threshold, discount):
# One copy, at the top: the caller's frame is not ours to change.
result = frame.copy()
qualifies = result['quantity'] >= threshold
# One indexing operation, not two — .loc takes the rows and the column
# together, so there is no temporary frame for the assignment to land in.
result.loc[qualifies, 'price'] *= discount
return result
The .copy() is about the caller: without it the function edits data it was only asked to read. The .loc is about the assignment: without it the edit goes nowhere at all. A version with the copy and without the .loc — the starter — is safe and useless, and a version with the .loc and without the copy works and has a side effect nobody asked for. Both are needed, for unrelated reasons.
result.loc[result['quantity'] >= threshold, 'price'] *= discount is correct and dense. Pulling the condition out into `qualifies` means the assignment line says what it does rather than how it selects — and it gives you something to inspect, count or reuse when the rule turns out to be wrong.
Copy-on-Write became the default in pandas 3.0, which removes SettingWithCopyWarning — chained assignment then reliably does nothing rather than sometimes working, and pandas raises a ChainedAssignmentError instead. That is a genuine improvement and it means the code you read online behaves differently depending on which version wrote it. The .loc form is correct on every version, which is the reason to write it by habit.
The pattern to avoid is specifically *assignment* through two brackets. Reading through two — frame[mask]['price'].mean() — is fine, merely wasteful, because it builds a temporary you immediately consume. And if you genuinely want the filtered rows as their own object to work on, say so: subset = frame[mask].copy(), and then assignments into subset are unambiguous and obviously not meant to reach the original. The explicit copy is what turns a bug into a decision.
- Add a second condition — a discount only on one product category — and check the mask still reads clearly when it is two comparisons.
- Write the in-place version, name it so the caller cannot miss it, and have it return None the way list.sort does.
- Run the broken version with warnings turned into errors — warnings.simplefilter('error') — and see the bug become impossible to miss.