TRENDING
An empty toll plaza entry with several lanes under a steel canopy, green arrow signals above the booths and a toll fare board beside the road
November 6, 2026
How to Spread Requests Across Servers in Python With the Power of Two Choices and Avoid the Herd Effect of Least-Loaded Routing
Cross-shaped arrow slit set in a recessed brick arch in a lichen-spotted castle wall at White Castle, Monmouthshire
November 6, 2026
Microsoft’s PDD Kit Turns Code Review Into Intent Review, but Verus Accepts a Login That Answers Every Attempt With a 500 Under the Contract the Post Prints
Several dried pufferfish with their spines raised hanging in a stack at a seaside market stall
November 6, 2026
OpenSSH Patches a Password Bypass on musl Systems Such as Alpine, Where a 257 to 1,024 Byte Password Logs In to Password-Disabled Accounts, and musl’s Maintainer Says the Bug Is Not in musl
Black silhouette of a wooden hourglass on a ledge against a grey cloudy sky with blurred bare branches
November 6, 2026
How to Stop Hung HTTP Calls in Python With Timeouts and Deadlines, and Catch the Slow-Drip Response That Slips Under requests, httpx, and urllib
Woodland path closed off with red and white barrier tape, traffic cones and a warning triangle
November 6, 2026
OpenAI’s “A Thousand Fewer Paths to Root” Turns $1.2 Million of API Credit Into 948 Candidate Patches, but Mainline Linux Shows 24 OpenAI-Linked Commits Since Mid-September and the Bounty Math Assumes Payouts the Rules Do Not Offer
06 Nov 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A chrome ticket punch lying on a bright blue surface, the tool that makes a ticket good for one use only
How to Secure a WebSocket Endpoint in FastAPI and Stop Cross-Site Hijacking, Leaked Tokens, Floods, and Slow Readers
November 6, 2026
Yellow stenciled words Mind the gap painted on a station platform edge beside the railway tracks
OpenAI’s Astra Report Turns Silent Safety-Task Underperformance Into a Matched-Pair Gap, but the Ethical-Doubt Result’s Bar Runs From a 4-Point Gap to a 3.6-Point Reversal, and the Launch Card Quoted AISI as Seeing Very Few Refusals
November 6, 2026
Black and white photo of a speckled stoneware honey pot with a lid and a wooden dipper standing in its opening, against a dark background
OpenAI Releases GPT-6 Cyber, Rated Critical, to Approved Customers: the Post’s 99.93% Scope Score Is Measured Against the Older Cyber Model, the System Card Puts Its Base Model at 0% Honeypot Exploitation, and One Outside Benchmark Has It Solving Fewer Challenges
November 6, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 285 Posts
News 289 Posts
Learning Hub 259 Posts
Home/Learning Hub/How to Migrate to pandas 3 Copy-on-Write and Fix Chained Assignment, Read-Only Arrays, and Silent Wrong Totals
Learning Hub

How to Migrate to pandas 3 Copy-on-Write and Fix Chained Assignment, Read-Only Arrays, and Silent Wrong Totals

A step-by-step pandas 3 migration tutorial: run one report on pandas 2.3.3 and 3.1.0, see four chained-assignment steps silently do nothing, rewrite them with .loc, and add a pytest gate and an AST...

November 4, 2026 45 Min Read
19

Upgrading to pandas 3 is easy to start and easy to get subtly wrong. You install it, run your scripts, and most of them finish without a single error. A few of them quietly produce different numbers. The cause is one change in how pandas treats data you pick out of a DataFrame. It is called Copy-on-Write, and in pandas 3 it is always on.

Table Of Content

  • The terms used in this tutorial
  • Prerequisites
  • Step 0: Create the project folder and two environments
  • The idea
  • The commands
  • Check that both versions are installed
  • The data and three small helpers
  • Check it worked
  • Common mistakes
  • Step 1: Watch one change reach a second object
  • The idea
  • The code
  • Run it on both versions
  • What each line tells you
  • Check it worked
  • Common mistakes
  • Step 2: Run a legacy report on both versions
  • The idea
  • The code
  • Run it on both versions
  • Reading the pandas 2.3.3 output
  • Reading the pandas 3.1.0 output
  • Check it worked
  • Common mistakes
  • Step 3: Understand chained assignment one pattern at a time
  • The idea
  • The code
  • Run it on both versions
  • Reading the results
  • The old way to hush the warnings no longer works
  • Check it worked
  • Common mistakes
  • Step 4: Rewrite each pattern and prove both versions agree
  • The idea
  • The code
  • Run it on both versions
  • Rewrite the report
  • Run the rewritten report on both versions
  • Why the answer is 491.60 and not 590.60
  • Check it worked
  • Common mistakes
  • Step 5: Fix read-only NumPy arrays and the Series constructor copy
  • The idea
  • The code
  • Run it on both versions
  • Reading the results
  • Check it worked
  • Common mistakes
  • Step 6: Measure what lazy copies save, and how a spare reference brings the copy back
  • The idea
  • The code
  • Run it on both versions
  • Reading the first table
  • Reading the second table
  • Check it worked
  • Common mistakes
  • Step 7: Find the leftovers before they reach production
  • 7a: Ask pandas 2.3 to warn at every difference
  • The idea
  • The code
  • Run it on both versions
  • Reading the results
  • Common mistakes
  • 7b: A pytest gate that turns the warnings into failures
  • The idea
  • Run it on both versions
  • What the gate cannot see
  • 7c: A static scanner for the lines no test reaches
  • The idea
  • The code
  • Run it
  • Test the scanner, then test the tests
  • The scanner’s limits
  • What Ruff covers
  • Step 8: Confirm the migration end to end
  • The final check
  • A migration checklist for your own project
  • One more default that changed
  • What to remember
  • Sources and further reading

pandas 3.0.0 was released on January 21, 2026, and 3.1.0 followed on October 22, 2026. The 3.0.0 release notes state the new rule in two sentences. First: “The result of any indexing operation (subsetting a DataFrame or Series in any way, i.e. including accessing a DataFrame column as a Series) or any method returning a new DataFrame or Series, always behaves as if it were a copy in terms of user API.” Then: “As a consequence, if you want to modify an object (DataFrame or Series), the only way to do this is to directly modify that object itself.” A great deal of pandas 2 code did the opposite. It picked something out of a DataFrame, changed the pick, and trusted the change to reach the original.

This tutorial takes you through a migration from start to finish. You will run one small sales report on pandas 2.3.3 and on pandas 3.1.0 and watch the revenue total change from 590.60 to 583.00 while four of the five steps quietly do nothing. You will find out why, rewrite the broken lines so that both versions agree on 491.60, and add two safety nets that catch the same kind of bug in the rest of your code. Along the way you will measure what Copy-on-Write saves, because it is not only a correctness change.

By the end you will be able to:

  • explain what a view and a copy are, and why pandas 2 handed you one or the other depending on details you could not see;
  • state the Copy-on-Write rule in one sentence and apply it to your own code;
  • recognize the code shapes that break: a column picked out and changed through a mask, rows picked out and then a column, an inplace method on a selected column, a change by position on a selected column, and a write into a NumPy array taken from pandas;
  • rewrite each one with .loc, .iloc or a plain assignment, and prove that pandas 2.3.3 and pandas 3.1.0 produce the same result;
  • explain two other changes people trip over, read-only NumPy arrays and the Series constructor copying the array you give it;
  • measure lazy copies, and see how one forgotten extra reference brings the copy back;
  • find the leftovers with pandas 2.3’s warn mode, a pytest gate and a small scanner built on Python’s ast module, and say what each of the three cannot see.

Everything below was run on Python 3.13.14 on Windows 11 on November 4, 2026, with pandas 2.3.3 (the newest 2.x release on PyPI when I checked), pandas 3.1.0, NumPy 2.5.4 and pytest 9.2.0. Every output block is copied from those runs. Timings differ from machine to machine, so compare the ratios and not the exact milliseconds.

The terms used in this tutorial

  • DataFrame and Series. A DataFrame is a table of columns. A Series is one column (or one row) of it. Numeric columns are stored in NumPy arrays underneath.
  • View. An object that looks at the same memory as another object. A change made through one is seen through the other, because there is only one copy of the numbers.
  • Copy. An object with its own memory. Changing it cannot change the original.
  • Copy-on-Write (CoW). The pandas 3 behavior: every object you get from another object acts as a copy. To save time and memory, pandas still shares the memory until someone writes to one of the objects, and only then makes the copy.
  • Lazy copy. A copy that is postponed until a write makes it necessary.
  • Chained assignment. Changing a DataFrame through two or more indexing steps in one statement, for example df["price"][mask] = 0.0. The first step selects, and the second step assigns into whatever the first step returned.
  • Mask. A Series of True and False values, such as df["qty"] > 5, used to choose rows.
  • .loc and .iloc. The two indexers that can select and assign in one step. .loc works with row and column labels, and .iloc works with integer positions.
  • Inplace method. A method called with inplace=True. It changes the object it is called on and returns None.
  • Read-only array. A NumPy array that refuses writes. Assigning into one raises ValueError.
  • ChainedAssignmentError. The warning class pandas 3 uses when it detects chained assignment. Despite the name it is a warning, not an exception, until you turn it into one.

Prerequisites

  • Python 3.11 or newer. pandas 3.1.0 requires it (its PyPI metadata says >=3.11). I used 3.13.14. Where I write python, use python3 if that is your command name.
  • Internet access to install pandas 2.3.3, pandas 3.1.0 and pytest from PyPI, and about 500 MB of free memory for Step 6.
  • Any operating system. I used Windows 11, so the commands below use the Windows folder name Scripts. On Linux and macOS use bin instead.
  • Basic pandas. You should be able to read df["price"], df[df["qty"] > 5] and df.loc[rows, "price"]. Everything else is explained when it first appears.
  • About two hours. The scripts run in seconds, except the memory test in Step 6, which takes about a minute.

Step 0: Create the project folder and two environments

The idea

To see what changed, you need pandas 2 and pandas 3 side by side. A virtual environment is an isolated folder with its own python command and its own installed packages, so two environments can hold two pandas versions without interfering. You will create pd2 for pandas 2.3.3 and pd3 for pandas 3.1.0. Instead of activating one, you will call each environment’s Python by its path, so every command says which pandas it uses.

The same release notes recommend this order of work: “When upgrading to pandas 3.0, it is recommended to first upgrade to pandas 2.3 to get deprecation warnings for a subset of those changes.” You will see exactly which of the changes pandas 2.3 warns about and which it does not.

The commands

mkdir pandas-cow-lab
cd pandas-cow-lab
python -m venv pd2
python -m venv pd3
pd2\Scripts\python -m pip install pandas==2.3.3 pytest==9.2.0
pd3\Scripts\python -m pip install pandas==3.1.0 pytest==9.2.0
mkdir lab
cd lab

The two venv commands create the environments. Each pip install runs through that environment’s own Python, so pandas lands in the right place. The last two lines make a lab folder for the scripts and move into it. From here on every command is run from lab, which is why the interpreters are reached one folder up, as ..\pd2\Scripts\python.

Check that both versions are installed

..\pd2\Scripts\python -c "import pandas; print(pandas.__version__)"
..\pd3\Scripts\python -c "import pandas; print(pandas.__version__)"

You should see the two versions, one per line:

2.3.3
3.1.0

The data and three small helpers

Create orders.py in the lab folder. Every later script imports it.

"""orders.py: the small data set and the helpers every step uses."""
import contextlib
import warnings

import pandas as pd


def make_orders():
    """Five orders: numbers, text, and a column with missing values."""
    return pd.DataFrame(
        {
            "order_id": [101, 102, 103, 104, 105],
            "region": ["EU", "US", "EU", "APAC", "US"],
            "qty": [3, 8, 1, 12, 5],
            "price": [20.0, 15.5, 99.0, 7.5, 42.0],
            "coupon": [None, "SAVE5", None, None, "SAVE5"],
        }
    )


def revenue(orders):
    """Quantity times price, summed, rounded to cents."""
    return round(float((orders["qty"] * orders["price"]).sum()), 2)


@contextlib.contextmanager
def watch():
    """Record every warning raised inside the with block."""
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        yield caught


def show(caught, width=88):
    """Print one short line for each recorded warning."""
    if not caught:
        print("    no warnings")
    for item in caught:
        text = " ".join(str(item.message).split())
        print(f"    {item.category.__name__}: {text[:width]}")

make_orders() returns the same five orders every time: an id, a region (text), a quantity, a price, and a coupon column with three missing values. revenue() multiplies quantity by price, adds it up and rounds to cents. It is the one number the report is supposed to get right, so we will watch it after every step. watch() and show() record the warnings raised inside a with block and print one short line for each. We need them because warnings are the main evidence in this tutorial and they differ between versions.

Check it worked

..\pd3\Scripts\python -c "from orders import make_orders, revenue; print(revenue(make_orders()))"
583.0

By hand: 3 × 20.0 + 8 × 15.5 + 1 × 99.0 + 12 × 7.5 + 5 × 42.0 = 60 + 124 + 99 + 90 + 210 = 583. If you see ModuleNotFoundError: No module named 'orders', you are not in the lab folder.

Common mistakes

  • Typing python instead of ..\pd2\Scripts\python. You then run whatever Python is first on your path, with whatever pandas it has. Always check the version line that each script prints first.
  • Using Scripts on Linux or macOS. There the folder is bin and the path uses forward slashes, for example ../pd2/bin/python.

Step 1: Watch one change reach a second object

The idea

Start with the smallest possible experiment. Pick the price column out of the DataFrame as a Series, change the first price in the Series, and look at the DataFrame. Then do it the other way around: pick the column, change the DataFrame, and look at the Series.

Under the hood, a Series picked from a DataFrame can be a view: a second object over the same numbers. pandas 2 decided between a view and a copy by rules that depended on the data types, the shape of the selection and the operation. The migration guide calls the result confusing: “The exact behavior was hard to predict. Copy-on-Write solves accidentally modifying more than one object, it explicitly disallows this.”

The code

"""step1_view_leak.py: change one object and see whether a second object changes behind your back."""
import pandas as pd
from orders import make_orders, show, watch

print("pandas", pd.__version__)

print("--- change the Series, read the DataFrame")
orders = make_orders()
price = orders["price"]  # pick one column out of the DataFrame
with watch() as caught:
    price.iloc[0] = 0.0  # change the first price in that Series
print("Series first price   :", price.iloc[0])
print("DataFrame first price:", orders.loc[0, "price"])
show(caught)

print("--- change the DataFrame, read the Series")
orders = make_orders()
price = orders["price"]
with watch() as caught:
    orders.loc[0, "price"] = 1.0  # change the DataFrame this time
print("DataFrame first price:", orders.loc[0, "price"])
print("Series first price   :", price.iloc[0])
show(caught)

Run it on both versions

..\pd2\Scripts\python step1_view_leak.py
..\pd3\Scripts\python step1_view_leak.py

pandas 2.3.3 prints:

pandas 2.3.3
--- change the Series, read the DataFrame
Series first price   : 0.0
DataFrame first price: 0.0
    SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame See the caveats in the
--- change the DataFrame, read the Series
DataFrame first price: 1.0
Series first price   : 1.0
    no warnings

pandas 3.1.0 prints:

pandas 3.1.0
--- change the Series, read the DataFrame
Series first price   : 0.0
DataFrame first price: 20.0
    no warnings
--- change the DataFrame, read the Series
DataFrame first price: 1.0
Series first price   : 20.0
    no warnings

What each line tells you

On pandas 2.3.3 the first experiment changed the Series and the DataFrame followed: both show 0.0. The second experiment ran the leak backwards. Changing the DataFrame to 1.0 changed the Series that had been picked out earlier. Only the first experiment drew a warning, a SettingWithCopyWarning that talks about “a copy of a slice” even though the write did reach the DataFrame. The second experiment, where the DataFrame changed a Series behind its back, printed no warning at all.

On pandas 3.1.0 each object keeps its own values. The Series shows 0.0 while the DataFrame still shows 20.0, and in the second experiment the Series still shows 20.0 after the DataFrame changed to 1.0. That is the rule from the release notes at work: the Series “always behaves as if it were a copy”.

Check it worked

Compare the second line of each block. DataFrame first price: 0.0 on pandas 2.3.3 against DataFrame first price: 20.0 on pandas 3.1.0 is the whole change in one line.

Common mistakes

  • Assuming the old behavior was a feature you can keep. Some code used it on purpose, for example to update a DataFrame through a helper that received one column. That code now changes only the helper’s local Series. Step 3 shows how to rewrite it.
  • Waiting for a warning. pandas 3.1.0 printed none for this script, because nothing is chained: the Series was picked out on one line and changed on another. pandas simply applies the rule, and the update that used to leak through is lost without a sound. Step 7 comes back to this blind spot.

Step 2: Run a legacy report on both versions

The idea

Real code rarely contains a toy leak. It contains reports, and reports contain five small cleanup steps that someone wrote years ago. The file below has five such steps, written the way pandas 2 allowed. Each step has a plain-English comment saying what it is supposed to do. You will run the steps one at a time and watch the revenue after each.

The code

Create legacy_report.py. It contains only the five steps; it has no printing and no data of its own.

"""legacy_report.py: five report steps written the way pandas 2 allowed."""


def discount(orders):
    # 1. Bulk discount: 10 percent off the price of orders with more than 5 items.
    orders["price"][orders["qty"] > 5] = orders["price"] * 0.9


def cancel(orders):
    # 2. Order 103 was cancelled, so its quantity becomes 0.
    orders[orders["order_id"] == 103]["qty"] = 0


def label_coupons(orders):
    # 3. Orders without a coupon get the label "none".
    orders["coupon"].fillna("none", inplace=True)


def correct_price(orders):
    # 4. Late correction: order 105 must be billed at 40.0, not 42.0.
    orders["price"].iloc[4] = 40.0


def price_floor(orders):
    # 5. The minimum billable price is 10.0, so raise anything lower.
    prices = orders["price"].to_numpy()
    prices[prices < 10.0] = 10.0

Now create run_report.py. It imports the module whose name you pass on the command line, runs the five steps in order on one set of orders, and prints the revenue before and after each step plus any warnings raised. Passing the module name lets you reuse the same runner for the rewritten report in Step 4.

"""run_report.py MODULE: run the five report steps one by one and show what each did to the revenue."""
import importlib
import sys

import pandas as pd
from orders import make_orders, revenue, show, watch

module = importlib.import_module(sys.argv[1])
print("pandas", pd.__version__, "| steps from", sys.argv[1] + ".py")
orders = make_orders()
print(f"{'start':<14} revenue {revenue(orders):7.2f}")
for name in ["discount", "cancel", "label_coupons", "correct_price", "price_floor"]:
    before = revenue(orders)
    note = ""
    with watch() as caught:
        try:
            getattr(module, name)(orders)
        except ValueError as error:
            note = f"  ValueError: {error}"
    print(f"{name:<14} revenue {before:7.2f} -> {revenue(orders):7.2f}{note}")
    if note == "":
        show(caught)
print("qty   :", orders["qty"].tolist())
print("price :", [round(price, 2) for price in orders["price"]])
print("coupon:", orders["coupon"].tolist())

Run it on both versions

..\pd2\Scripts\python run_report.py legacy_report
..\pd3\Scripts\python run_report.py legacy_report

pandas 2.3.3 prints:

pandas 2.3.3 | steps from legacy_report.py
start          revenue  583.00
discount       revenue  583.00 ->  561.60
    FutureWarning: ChainedAssignmentError: behaviour will change in pandas 3.0! You are setting values thro
    SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame See the caveats in the
cancel         revenue  561.60 ->  561.60
    SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_in
label_coupons  revenue  561.60 ->  561.60
    FutureWarning: A value is trying to be set on a copy of a DataFrame or Series through chained assignmen
correct_price  revenue  561.60 ->  551.60
    FutureWarning: ChainedAssignmentError: behaviour will change in pandas 3.0! You are setting values thro
    SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame See the caveats in the
price_floor    revenue  551.60 ->  590.60
    no warnings
qty   : [3, 8, 1, 12, 5]
price : [20.0, 13.95, 99.0, 10.0, 40.0]
coupon: ['none', 'SAVE5', 'none', 'none', 'SAVE5']

pandas 3.1.0 prints:

pandas 3.1.0 | steps from legacy_report.py
start          revenue  583.00
discount       revenue  583.00 ->  583.00
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Such
cancel         revenue  583.00 ->  583.00
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Such
label_coupons  revenue  583.00 ->  583.00
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment using
correct_price  revenue  583.00 ->  583.00
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Such
price_floor    revenue  583.00 ->  583.00  ValueError: assignment destination is read-only
qty   : [3, 8, 1, 12, 5]
price : [20.0, 15.5, 99.0, 7.5, 42.0]
coupon: [nan, 'SAVE5', nan, nan, 'SAVE5']

Reading the pandas 2.3.3 output

  • discount works: revenue drops from 583.00 to 561.60. The two orders with more than five items get 10 percent off, and pandas warns that this will stop working in 3.0.
  • cancel does nothing: 561.60 stays 561.60. Order 103 is still billed. The SettingWithCopyWarning says why: the rows were selected first, which produced a copy, and the assignment went into the copy. This report has been billing a cancelled order all along, and nobody noticed because nothing crashed.
  • label_coupons works. The revenue does not move, since coupons are not part of it, but the final coupon list at the bottom shows the labels. pandas warns that an inplace method on a selection will stop working.
  • correct_price works: 561.60 becomes 551.60 because order 105 drops from 42.0 to 40.0 (5 items, so 10.00 less).
  • price_floor works and raises revenue to 590.60, with no warning at all. Order 104 had been discounted to 6.75, and the floor lifted it to 10.0 (12 items, so 39.00 more). The change travelled through a NumPy array taken from the Series. Step 5 explains how.

Reading the pandas 3.1.0 output

  • The revenue stays at 583.00 through the first four steps. Each of them did nothing, and each printed a ChainedAssignmentError warning. No exception stopped the report.
  • price_floor raised ValueError: assignment destination is read-only. That one is loud, which is good.
  • The final lists show the orders exactly as they started: quantities and prices untouched, and the coupon column still holding nan for the missing values.

So the same script gives 590.60 on one version and 583.00 on the other. Neither is right. The correct answer needs all five steps to work, and by the end of Step 4 it will be 491.60. The dangerous part is the middle of the pandas 3.1.0 output: four steps that silently did nothing. If you had not been looking at warnings, that report would have shipped.

Check it worked

You should see 590.60 as the last revenue on pandas 2.3.3, and 583.00 on every line on pandas 3.1.0 followed by the ValueError on the last step.

Common mistakes

  • Trusting a run with no exception. The failure mode of this migration is wrong numbers, not crashes.
  • Letting warnings disappear. Logging setups and -W ignore flags hide them. Step 7 turns them into test failures so nobody has to read them.

Step 3: Understand chained assignment one pattern at a time

The idea

The four broken steps in the report share one idea. Take orders["price"][orders["qty"] > 5] = 0.0. Python runs it in two moves. The first move, orders["price"], selects a column and hands back a Series. The second move assigns into that Series. In pandas 2 the Series was sometimes a window onto the DataFrame’s own numbers, so the assignment reached the DataFrame. In pandas 3 the Series is always a separate object, so the assignment lands in a temporary value that is thrown away. The pandas reference page for ChainedAssignmentError puts it in one sentence: “With Copy-on-Write now always enabled, chained assignment can never work.”

Four shapes of it turn up in real code. The script below runs each one on a fresh copy of the data and counts how many rows the change actually reached.

The code

"""step3_chained.py: four chained patterns, each run on a fresh copy of the data."""
import pandas as pd
from orders import make_orders, show, watch


def mask_on_column(orders):
    orders["price"][orders["qty"] > 5] = 0.0


def column_on_rows(orders):
    orders[orders["qty"] > 5]["price"] = 0.0


def inplace_on_column(orders):
    orders["coupon"].fillna("none", inplace=True)


def iloc_on_column(orders):
    orders["price"].iloc[0] = 0.0


CASES = [
    ('orders["price"][orders["qty"] > 5] = 0.0', mask_on_column, lambda o: int((o["price"] == 0.0).sum()), 2),
    ('orders[orders["qty"] > 5]["price"] = 0.0', column_on_rows, lambda o: int((o["price"] == 0.0).sum()), 2),
    ('orders["coupon"].fillna("none", inplace=True)', inplace_on_column, lambda o: int((o["coupon"] == "none").sum()), 3),
    ('orders["price"].iloc[0] = 0.0', iloc_on_column, lambda o: int((o["price"] == 0.0).sum()), 1),
]

print("pandas", pd.__version__)
for label, function, count, wanted in CASES:
    orders = make_orders()
    with watch() as caught:
        function(orders)
    print(label)
    print(f"    reached the DataFrame: {count(orders)} of {wanted} rows")
    show(caught)

print("pd.errors.SettingWithCopyWarning exists:", hasattr(pd.errors, "SettingWithCopyWarning"))

print("--- the old way to hush the warnings, then the first pattern again")
orders = make_orders()
with watch() as caught:
    pd.options.mode.chained_assignment = None
    mask_on_column(orders)
show(caught)

Each case is a label, a function that does the chained operation, a function that counts the rows that changed, and the number of rows that should have changed. The last block at the bottom tries the old trick for silencing the warnings, which we will come back to.

Run it on both versions

..\pd2\Scripts\python step3_chained.py
..\pd3\Scripts\python step3_chained.py

pandas 2.3.3 prints:

pandas 2.3.3
orders["price"][orders["qty"] > 5] = 0.0
    reached the DataFrame: 2 of 2 rows
    FutureWarning: ChainedAssignmentError: behaviour will change in pandas 3.0! You are setting values thro
    SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame See the caveats in the
orders[orders["qty"] > 5]["price"] = 0.0
    reached the DataFrame: 0 of 2 rows
    SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame. Try using .loc[row_in
orders["coupon"].fillna("none", inplace=True)
    reached the DataFrame: 3 of 3 rows
    FutureWarning: A value is trying to be set on a copy of a DataFrame or Series through chained assignmen
orders["price"].iloc[0] = 0.0
    reached the DataFrame: 1 of 1 rows
    FutureWarning: ChainedAssignmentError: behaviour will change in pandas 3.0! You are setting values thro
    SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame See the caveats in the
pd.errors.SettingWithCopyWarning exists: True
--- the old way to hush the warnings, then the first pattern again
    FutureWarning: ChainedAssignmentError: behaviour will change in pandas 3.0! You are setting values thro

pandas 3.1.0 prints:

pandas 3.1.0
orders["price"][orders["qty"] > 5] = 0.0
    reached the DataFrame: 0 of 2 rows
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Such
orders[orders["qty"] > 5]["price"] = 0.0
    reached the DataFrame: 0 of 2 rows
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Such
orders["coupon"].fillna("none", inplace=True)
    reached the DataFrame: 0 of 3 rows
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment using
orders["price"].iloc[0] = 0.0
    reached the DataFrame: 0 of 1 rows
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Such
pd.errors.SettingWithCopyWarning exists: False
--- the old way to hush the warnings, then the first pattern again
    Pandas4Warning: The 'mode.chained_assignment' option is deprecated. It has had no effect since pandas 3.
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Such

Reading the results

  • A mask on a selected column. On pandas 2.3.3 it worked (2 of 2 rows) and warned twice. On pandas 3.1.0 it reached 0 of 2.
  • A column on selected rows. It reached 0 of 2 on both versions. Choosing rows with a mask always made a copy, so this shape never worked in pandas 2 either. The old signal was SettingWithCopyWarning.
  • An inplace method on a selected column. It reached 3 of 3 rows on pandas 2.3.3 with a FutureWarning and 0 of 3 on pandas 3.1.0.
  • .iloc on a selected column. It reached 1 of 1 on pandas 2.3.3 and 0 of 1 on pandas 3.1.0.

The line about pd.errors.SettingWithCopyWarning shows what happened to the old warning: it exists on pandas 2.3.3 and is gone on pandas 3.1.0. The release notes explain: “Because this now consistently never works, the SettingWithCopyWarning is removed, and defensive .copy() calls to silence the warning are no longer needed.” In its place pandas 3 raises ChainedAssignmentError, which is why every warning line on the right says that name.

The old way to hush the warnings no longer works

A very common habit in pandas 2 code was pd.options.mode.chained_assignment = None, which silenced SettingWithCopyWarning. The last block of each output shows what that line does now. On pandas 2.3.3 the SettingWithCopyWarning is gone but the FutureWarning about the coming change still appears. On pandas 3.1.0 setting the option raises a Pandas4Warning saying the option is deprecated, and the ChainedAssignmentError still appears. The 3.1.0 release notes confirm it: “It has had no effect since pandas 3.0 removed SettingWithCopyWarning; chained assignment now emits a ChainedAssignmentError instead, which can be silenced or turned into an error with warnings.filterwarnings” and a category argument. That second half is the hook for the test gate in Step 7.

Check it worked

On pandas 3.1.0 you should see four lines that read reached the DataFrame: 0 of followed by a row count. On pandas 2.3.3 only the second pattern shows 0, and the other three reach every row they should.

Common mistakes

  • Thinking inplace=True has been removed. It has not. Step 4 shows orders.fillna({"coupon": "none"}, inplace=True) working on both versions. What stopped working is an inplace method on a selection, because the selection is a temporary copy.
  • Treating a silenced warning as a fixed bug. In pandas 3 the warning is the only sign that an assignment was thrown away.

Step 4: Rewrite each pattern and prove both versions agree

The idea

The fix follows from the rule. If the only way to change an object is to change that object itself, name the DataFrame and choose the rows and the column in the same step. That is what .loc and .iloc do when you assign through them. The migration guide says it for the mask case: “With copy on write this can be done by using loc.” This table maps each broken shape to a replacement:

Breaks on pandas 3 Works on both versions
orders["price"][mask] = 0.0 orders.loc[mask, "price"] = 0.0
orders[mask]["price"] = 0.0 orders.loc[mask, "price"] = 0.0
orders["coupon"].fillna("none", inplace=True) orders.fillna({"coupon": "none"}, inplace=True) or orders["coupon"] = orders["coupon"].fillna("none")
orders["price"].iloc[0] = 0.0 orders.iloc[0, orders.columns.get_loc("price")] = 0.0
a write into orders["price"].to_numpy() do the work in pandas, or copy the array first (Step 5)

Two details come straight from the guide. For positions, .iloc takes integers for both rows and columns, so you translate the column name with get_loc. The guide notes that the alternative, converting the row position to its label for .loc, “does more work and is only equivalent if the DataFrame has a unique row index.” And when you hold a boolean mask and want to replace values, the guide adds: “If you have a boolean mask, DataFrame.where() is another suitable alternative for this case.” My second rewrite below uses Series.mask, the sibling method that replaces values where the condition is True.

The code

"""step4_fixes.py: the same four jobs written so they work on pandas 2.3 and on pandas 3.x."""
import pandas as pd
from orders import make_orders, show, watch


def mask_with_loc(orders):
    orders.loc[orders["qty"] > 5, "price"] = 0.0


def mask_whole_column(orders):
    orders["price"] = orders["price"].mask(orders["qty"] > 5, 0.0)


def fill_with_dataframe_call(orders):
    orders.fillna({"coupon": "none"}, inplace=True)


def fill_by_assignment(orders):
    orders["coupon"] = orders["coupon"].fillna("none")


def set_by_position(orders):
    orders.iloc[0, orders.columns.get_loc("price")] = 0.0


CASES = [
    ('orders.loc[orders["qty"] > 5, "price"] = 0.0', mask_with_loc, lambda o: int((o["price"] == 0.0).sum()), 2),
    ('orders["price"] = orders["price"].mask(orders["qty"] > 5, 0.0)', mask_whole_column, lambda o: int((o["price"] == 0.0).sum()), 2),
    ('orders.fillna({"coupon": "none"}, inplace=True)', fill_with_dataframe_call, lambda o: int((o["coupon"] == "none").sum()), 3),
    ('orders["coupon"] = orders["coupon"].fillna("none")', fill_by_assignment, lambda o: int((o["coupon"] == "none").sum()), 3),
    ('orders.iloc[0, orders.columns.get_loc("price")] = 0.0', set_by_position, lambda o: int((o["price"] == 0.0).sum()), 1),
]

print("pandas", pd.__version__)
for label, function, count, wanted in CASES:
    orders = make_orders()
    with watch() as caught:
        function(orders)
    print(label)
    print(f"    reached the DataFrame: {count(orders)} of {wanted} rows")
    show(caught)

Run it on both versions

..\pd2\Scripts\python step4_fixes.py
..\pd3\Scripts\python step4_fixes.py

pandas 2.3.3 prints:

pandas 2.3.3
orders.loc[orders["qty"] > 5, "price"] = 0.0
    reached the DataFrame: 2 of 2 rows
    no warnings
orders["price"] = orders["price"].mask(orders["qty"] > 5, 0.0)
    reached the DataFrame: 2 of 2 rows
    no warnings
orders.fillna({"coupon": "none"}, inplace=True)
    reached the DataFrame: 3 of 3 rows
    no warnings
orders["coupon"] = orders["coupon"].fillna("none")
    reached the DataFrame: 3 of 3 rows
    no warnings
orders.iloc[0, orders.columns.get_loc("price")] = 0.0
    reached the DataFrame: 1 of 1 rows
    no warnings

pandas 3.1.0 prints:

pandas 3.1.0
orders.loc[orders["qty"] > 5, "price"] = 0.0
    reached the DataFrame: 2 of 2 rows
    no warnings
orders["price"] = orders["price"].mask(orders["qty"] > 5, 0.0)
    reached the DataFrame: 2 of 2 rows
    no warnings
orders.fillna({"coupon": "none"}, inplace=True)
    reached the DataFrame: 3 of 3 rows
    no warnings
orders["coupon"] = orders["coupon"].fillna("none")
    reached the DataFrame: 3 of 3 rows
    no warnings
orders.iloc[0, orders.columns.get_loc("price")] = 0.0
    reached the DataFrame: 1 of 1 rows
    no warnings

The two outputs are identical apart from the version line. Every rewrite reaches every row it should, and there are no warnings on either version. That is the goal of a good migration: code that behaves the same on the old and the new version, so you can upgrade at a time of your choosing.

Rewrite the report

Now apply the table to the five report steps. Create report.py. The function names match legacy_report.py so the same runner and the same tests can drive both.

"""report.py: the same five steps, rewritten for Copy-on-Write. Same result on pandas 2.3 and 3.x."""


def discount(orders):
    orders.loc[orders["qty"] > 5, "price"] *= 0.9


def cancel(orders):
    orders.loc[orders["order_id"] == 103, "qty"] = 0


def label_coupons(orders):
    orders.fillna({"coupon": "none"}, inplace=True)


def correct_price(orders):
    orders.iloc[4, orders.columns.get_loc("price")] = 40.0


def price_floor(orders):
    orders["price"] = orders["price"].clip(lower=10.0)

Step by step: discount now multiplies the selected prices in place with one .loc statement instead of building a Series and assigning it back. cancel selects the rows and the qty column together. label_coupons calls fillna on the DataFrame itself with a dictionary of column names to fill values, which the guide lists as one of its two rewrites for an inplace method on a column (“This can generally be rewritten in 2 different forms”). correct_price uses .iloc with the column position. price_floor no longer touches NumPy at all: clip(lower=10.0) raises every price below 10 to 10 and returns a new Series, which we assign back to the column.

Run the rewritten report on both versions

..\pd2\Scripts\python run_report.py report
..\pd3\Scripts\python run_report.py report

pandas 2.3.3 prints:

pandas 2.3.3 | steps from report.py
start          revenue  583.00
discount       revenue  583.00 ->  561.60
    no warnings
cancel         revenue  561.60 ->  462.60
    no warnings
label_coupons  revenue  462.60 ->  462.60
    no warnings
correct_price  revenue  462.60 ->  452.60
    no warnings
price_floor    revenue  452.60 ->  491.60
    no warnings
qty   : [3, 8, 0, 12, 5]
price : [20.0, 13.95, 99.0, 10.0, 40.0]
coupon: ['none', 'SAVE5', 'none', 'none', 'SAVE5']

pandas 3.1.0 prints:

pandas 3.1.0 | steps from report.py
start          revenue  583.00
discount       revenue  583.00 ->  561.60
    no warnings
cancel         revenue  561.60 ->  462.60
    no warnings
label_coupons  revenue  462.60 ->  462.60
    no warnings
correct_price  revenue  462.60 ->  452.60
    no warnings
price_floor    revenue  452.60 ->  491.60
    no warnings
qty   : [3, 8, 0, 12, 5]
price : [20.0, 13.95, 99.0, 10.0, 40.0]
coupon: ['none', 'SAVE5', 'none', 'none', 'SAVE5']

Both versions now apply all five steps and reach 491.60, with no warnings. To prove that nothing else differs, compare.py runs the report on both environments and prints only the lines that changed.

"""compare.py MODULE: run run_report.py on pandas 2.3.3 and on pandas 3.1.0 and print the lines that differ."""
import difflib
import subprocess
import sys

BIN = "Scripts" if sys.platform == "win32" else "bin"


def run(environment):
    python = f"../{environment}/{BIN}/python"
    done = subprocess.run([python, "run_report.py", sys.argv[1]], capture_output=True, text=True, encoding="utf-8")
    return done.stdout.splitlines()


old, new = run("pd2"), run("pd3")
diff = difflib.unified_diff(old, new, "pandas 2.3.3", "pandas 3.1.0", lineterm="", n=0)
changes = [line for line in diff if line[:1] in "+-" and line[:3] not in ("+++", "---")]
print(f"{len(old)} lines from pandas 2.3.3, {len(new)} lines from pandas 3.1.0, {len(changes)} changed lines")
for line in changes:
    print(line)
..\pd3\Scripts\python compare.py report
15 lines from pandas 2.3.3, 15 lines from pandas 3.1.0, 2 changed lines
-pandas 2.3.3 | steps from report.py
+pandas 3.1.0 | steps from report.py

Two changed lines out of fifteen: the version header. Every revenue figure, quantity, price and coupon is the same on both versions.

Why the answer is 491.60 and not 590.60

The old pandas 2.3.3 run ended at 590.60. The rewritten run ends at 491.60, which is 99.00 lower. That is order 103: one item at 99.00 that the old cancel step never managed to cancel. In the rewritten version the quantities are 3, 8, 0, 12 and 5, the prices are 20.0, 13.95, 99.0, 10.0 and 40.0, and 60 + 111.60 + 0 + 120 + 200 gives 491.60. The migration did not only make the report portable. It exposed a billing error that pandas 2 had been hiding.

Check it worked

The compare.py output must show exactly two changed lines, both of them the version header. If any revenue line differs, one of your rewrites does not behave the same on both versions.

Common mistakes

  • Using .loc with a position. orders.loc[4, "price"] means “the row labelled 4”, which equals position 4 only while the index is the default 0, 1, 2 and so on. After a filter or a sort the labels no longer match positions. Use .iloc with get_loc when you mean a position.
  • Putting a second subscript after .loc. orders.loc[mask]["price"] = 0.0 is still chained: the first step selects rows and the second assigns into the result. Put the column inside the same brackets, as in orders.loc[mask, "price"].

Step 5: Fix read-only NumPy arrays and the Series constructor copy

The idea

Two more changes come from the same rule. Neither involves chained indexing, so the warnings from Step 3 never catch them.

The first is the array you get from to_numpy(). In pandas 2 it was a window onto the Series’ own numbers and it was writable, so writing into it changed the Series and the DataFrame. The legacy price_floor relied on exactly that. In pandas 3 the guide says the array “is set to non-writeable to protect against this behavior.”

The second is the constructor. The guide says: “The Series and DataFrame constructors now copies a NumPy array by default when not otherwise specified.” It also gives the way out: “You can set copy=False to avoid this copy.”

The code

"""step5_arrays.py: the NumPy array you get from pandas, and the array you hand to pandas."""
import numpy as np
import pandas as pd
from orders import make_orders

print("pandas", pd.__version__)

print("--- an array taken out of a Series")
orders = make_orders()
values = orders["price"].to_numpy()
print("array is writeable        :", values.flags.writeable)
try:
    values[3] = 0.0
    print("write worked")
except ValueError as error:
    print("write failed, ValueError:", error)
print("DataFrame price[3] after  :", orders["price"].iloc[3])

print("--- the same job with to_numpy(copy=True)")
orders = make_orders()
values = orders["price"].to_numpy(copy=True)
values[3] = 0.0
print("array is writeable        :", values.flags.writeable)
print("DataFrame price[3] after  :", orders["price"].iloc[3])

print("--- an array handed to the Series constructor")
source = np.array([1, 2, 3])
series = pd.Series(source)
source[0] = 100  # change the array after building the Series
print("Series(array), array[0]=100         -> Series[0] is", series.iloc[0])
shared = pd.Series(source, copy=False)
source[1] = 200
print("Series(array, copy=False), array[1]=200 -> Series[1] is", shared.iloc[1])

Run it on both versions

..\pd2\Scripts\python step5_arrays.py
..\pd3\Scripts\python step5_arrays.py

pandas 2.3.3 prints:

pandas 2.3.3
--- an array taken out of a Series
array is writeable        : True
write worked
DataFrame price[3] after  : 0.0
--- the same job with to_numpy(copy=True)
array is writeable        : True
DataFrame price[3] after  : 7.5
--- an array handed to the Series constructor
Series(array), array[0]=100         -> Series[0] is 100
Series(array, copy=False), array[1]=200 -> Series[1] is 200

pandas 3.1.0 prints:

pandas 3.1.0
--- an array taken out of a Series
array is writeable        : False
write failed, ValueError: assignment destination is read-only
DataFrame price[3] after  : 7.5
--- the same job with to_numpy(copy=True)
array is writeable        : True
DataFrame price[3] after  : 7.5
--- an array handed to the Series constructor
Series(array), array[0]=100         -> Series[0] is 1
Series(array, copy=False), array[1]=200 -> Series[1] is 200

Reading the results

  • An array taken out of a Series. On pandas 2.3.3 the array is writeable, the write works, and the DataFrame’s price changes from 7.5 to 0.0 as a side effect. On pandas 3.1.0 the array reports writeable: False, the write raises ValueError: assignment destination is read-only, and the DataFrame still holds 7.5.
  • The same job with to_numpy(copy=True). On both versions you get a writeable private array, and the DataFrame stays at 7.5. This is the safe way when you really need an array you can modify.
  • An array handed to the constructor. After pd.Series(source) and a change to source[0], the Series shows 100 on pandas 2.3.3 (it shared the array) and 1 on pandas 3.1.0 (it made its own copy). With copy=False the two share memory on both versions, so a change to source[1] shows up as 200.

The guide offers one more way out: make the array writeable again by setting its flags.writeable to True. It adds that this “is a more performant solution but circumvents Copy-on-Write rules, so it should be used with caution.” Treat it as a last resort. A better fix is usually to do the work in pandas, as the rewritten price_floor does with clip.

Check it worked

On pandas 3.1.0 the first block must end with DataFrame price[3] after : 7.5, and the constructor line must show 1. If the first block shows 0.0, you are running pandas 2.3.3.

Common mistakes

  • Searching only for chained indexing. Also search for code that takes an array out of pandas (to_numpy(), .values) and then assigns into it, and for helper functions that write in place into an array they are given. Step 7 shows that pandas 2.3’s warn mode does not flag the array write.
  • Relying on the old sharing in the other direction. If your code builds a Series from an array and later changes the array expecting the Series to follow, pandas 3 stops that unless you pass copy=False.

Step 6: Measure what lazy copies save, and how a spare reference brings the copy back

The idea

Copy-on-Write is also a performance feature. The release notes list it as a goal: “A secondary goal is to improve performance by avoiding unnecessary copies.” Because every result behaves as a copy, pandas is free to share memory between the original and the result and to copy only when one of them is about to be written. This is the lazy copy from the glossary.

The script below builds a frame of 2,000,000 rows and 8 float columns, which is 128 MB of numbers. First it times six operations and measures the extra memory each allocates. Then it takes reset_index(drop=True) and writes to the result twice, in two situations: the original frame is still referenced, and the original has been reassigned away, like big = big.reset_index(drop=True).

The code

"""step6_lazy.py: where does the copying cost go? Each frame is 2,000,000 rows by 8 float columns (128 MB)."""
import statistics
import time
import tracemalloc

import numpy as np
import pandas as pd

ROWS = 2_000_000
COLUMNS = list("abcdefgh")


def make_big():
    rng = np.random.default_rng(0)
    return pd.DataFrame({name: rng.random(ROWS) for name in COLUMNS})


OPERATIONS = [
    ("big.copy()", lambda big: big.copy()),
    ("big.reset_index(drop=True)", lambda big: big.reset_index(drop=True)),
    ("big.rename(columns=str.upper)", lambda big: big.rename(columns=str.upper)),
    ("big.drop(columns=['h'])", lambda big: big.drop(columns=["h"])),
    ("big.iloc[:, :4]", lambda big: big.iloc[:, :4]),
    ("big[['a', 'b', 'c']]", lambda big: big[["a", "b", "c"]]),
]


def median_ms(call, repeats=5):
    times = []
    for _ in range(repeats):
        big = make_big()
        start = time.perf_counter()
        call(big)
        times.append((time.perf_counter() - start) * 1000)
    return statistics.median(times)


def memory_and_sharing(call):
    big = make_big()
    tracemalloc.start()
    result = call(big)
    peak_mb = tracemalloc.get_traced_memory()[1] / 1e6
    tracemalloc.stop()
    shares = bool(np.may_share_memory(big.iloc[:, 0].to_numpy(), result.iloc[:, 0].to_numpy()))
    return peak_mb, shares


def write_costs(keep_original):
    original = make_big()
    start = time.perf_counter()
    frame = original.reset_index(drop=True)
    call_ms = (time.perf_counter() - start) * 1000
    if not keep_original:
        del original  # like  big = big.reset_index(drop=True)
    tracemalloc.start()
    start = time.perf_counter()
    frame.iloc[0, 0] = -1.0
    first_ms = (time.perf_counter() - start) * 1000
    first_mb = tracemalloc.get_traced_memory()[1] / 1e6
    tracemalloc.stop()
    start = time.perf_counter()
    frame.iloc[1, 0] = -2.0
    second_ms = (time.perf_counter() - start) * 1000
    return call_ms, first_ms, first_mb, second_ms


print("pandas", pd.__version__)
print(f"{'operation':<32}{'median ms':>10}{'extra MB':>10}  shares memory with big")
for label, call in OPERATIONS:
    peak_mb, shares = memory_and_sharing(call)
    print(f"{label:<32}{median_ms(call):>10.2f}{peak_mb:>10.1f}  {shares}")

print()
print("reset_index(drop=True), then two writes to the result (median of 5 runs)")
print(f"{'case':<28}{'call ms':>9}{'1st write ms':>14}{'1st write MB':>14}{'2nd write ms':>14}")
for label, keep in [("original still referenced", True), ("original reassigned away", False)]:
    runs = [write_costs(keep) for _ in range(5)]
    call_ms, first_ms, first_mb, second_ms = (statistics.median(column) for column in zip(*runs))
    print(f"{label:<28}{call_ms:>9.2f}{first_ms:>14.2f}{first_mb:>14.1f}{second_ms:>14.2f}")

Run it on both versions

This needs about 500 MB of free memory and takes about a minute for each version.

..\pd2\Scripts\python step6_lazy.py
..\pd3\Scripts\python step6_lazy.py

pandas 2.3.3 prints:

pandas 2.3.3
operation                        median ms  extra MB  shares memory with big
big.copy()                           40.09     128.0  False
big.reset_index(drop=True)           39.54     128.0  False
big.rename(columns=str.upper)        39.03     128.0  False
big.drop(columns=['h'])              36.05     112.0  False
big.iloc[:, :4]                       0.17       0.0  True
big[['a', 'b', 'c']]                 15.39      48.0  False

reset_index(drop=True), then two writes to the result (median of 5 runs)
case                          call ms  1st write ms  1st write MB  2nd write ms
original still referenced       20.83          0.23           0.0          0.05
original reassigned away        21.60          0.27           0.0          0.04

pandas 3.1.0 prints:

pandas 3.1.0
operation                        median ms  extra MB  shares memory with big
big.copy()                           38.92     128.0  False
big.reset_index(drop=True)            0.08       0.0  True
big.rename(columns=str.upper)         0.15       0.0  True
big.drop(columns=['h'])               0.39       0.0  True
big.iloc[:, :4]                       0.18       0.0  True
big[['a', 'b', 'c']]                  0.57       0.0  True

reset_index(drop=True), then two writes to the result (median of 5 runs)
case                          call ms  1st write ms  1st write MB  2nd write ms
original still referenced        0.08          5.13          16.0          0.12
original reassigned away         0.10          0.28           0.0          0.04

Reading the first table

On pandas 2.3.3, copy, reset_index, rename and drop all copy the data: 36 to 40 milliseconds and 112 to 128 MB each. Selecting a list of three columns copies 48 MB, and only the column slice big.iloc[:, :4] is a view. On pandas 3.1.0 only big.copy() copies (38.92 ms, 128.0 MB). The other five operations share memory with big, take under a millisecond and allocate nothing. The release notes describe the mechanism: “every new DataFrame or Series returned from an indexing operation or method behaves as a copy, but under the hood pandas will use views as much as possible.”

Reading the second table

On pandas 2.3.3 the cost is paid at the call: about 21 milliseconds for reset_index, then cheap writes. On pandas 3.1.0 the call costs 0.08 to 0.10 milliseconds and the cost moves to the first write, but only if something else still refers to the original. With the original still referenced, the first write took 5.13 milliseconds and allocated 16.0 MB, which is exactly one column of 2,000,000 values of 8 bytes. pandas copied only the column being written, not the whole 128 MB frame. The second write took 0.12 milliseconds because the column was already private. With the original reassigned away, the first write took 0.28 milliseconds and allocated nothing, because no other object shared the data any more.

The guide states the practical rule: “Creating multiple references keeps unnecessary references alive and thus will hurt performance with Copy-on-Write.” It also explains the habit that avoids the copy: “you can emulate an inplace-like operation through assigning the output of reset_index to the same variable.”

Check it worked

On pandas 3.1.0 the first table must show shares memory with big as True for five of the six rows, and the second table must show 16.0 for the first write in the first row and 0.0 in the second. The millisecond values will differ on your machine, and the two tables on pandas 2.3.3 differ from each other (about 40 against about 21 milliseconds for the same operation) because the memory allocator behaves differently in each run. Compare rows within one table and not across tables.

Common mistakes

  • Deleting every .copy(). A .copy() that only existed to silence SettingWithCopyWarning can go. But .copy() still does what it always did: an eager deep copy, 38.92 milliseconds and 128.0 MB in the first table.
  • Keeping a pristine copy of the original in a variable “just in case” while you edit the derived frame. Each such reference makes the first write copy the data it touches.

Step 7: Find the leftovers before they reach production

You have rewritten the lines you know about. The harder job is finding the ones you do not know about, in a codebase of hundreds of files. This step uses three tools of increasing strictness: pandas 2.3’s own warn mode, a pytest gate, and a small static scanner. Each one has a blind spot, and knowing the blind spots is the point.

7a: Ask pandas 2.3 to warn at every difference

The idea

The pandas 2.3.3 documentation explains two levels of warning. The default mode “will raise warnings for certain cases that will actively change behavior”, which is what you saw in Steps 2 and 3. There is also a stricter mode, switched on with pd.options.mode.copy_on_write = "warn", that warns “for every operation that will change behavior with CoW.” The guide is candid about it: “We expect this mode to be very noisy, since many cases that we don’t expect that they will influence users will also emit a warning.” It also says which cases matter: “The first two items of the following lists are the only cases that need to be addressed to make existing code work with CoW.” In that list the first item is chained assignment and the second is read-only NumPy arrays.

The code

"""step7_warn_mode.py: ask pandas 2.3 to warn at every place where pandas 3 will behave differently."""
import pandas as pd
from orders import make_orders, show, watch

print("pandas", pd.__version__)
with watch() as caught:
    pd.options.mode.copy_on_write = "warn"
    current = pd.options.mode.copy_on_write
print("setting and reading mode.copy_on_write:")
show(caught, width=100)
print("the option reads back as:", current)


def view_leak(orders):
    price = orders["price"]
    price.iloc[0] = 0.0


def chained_mask(orders):
    orders["price"][orders["qty"] > 5] = 0.0


def inplace_on_column(orders):
    orders["coupon"].fillna("none", inplace=True)


def numpy_write(orders):
    values = orders["price"].to_numpy()
    values[0] = 0.0


def clean_loc(orders):
    orders.loc[orders["qty"] > 5, "price"] = 0.0


for function in [view_leak, chained_mask, inplace_on_column, numpy_write, clean_loc]:
    orders = make_orders()
    print(function.__name__)
    failed = False
    with watch() as caught:
        try:
            function(orders)
        except ValueError as error:
            failed = True
            print(f"    ValueError: {error}")
    if caught or not failed:
        show(caught, width=100)

Run it on both versions

..\pd2\Scripts\python step7_warn_mode.py
..\pd3\Scripts\python step7_warn_mode.py

pandas 2.3.3 prints:

pandas 2.3.3
setting and reading mode.copy_on_write:
    no warnings
the option reads back as: warn
view_leak
    FutureWarning: Setting a value on a view: behaviour will change in pandas 3.0. You are mutating a Series or DataFra
chained_mask
    FutureWarning: ChainedAssignmentError: behaviour will change in pandas 3.0! You are setting values through chained
inplace_on_column
    FutureWarning: A value is trying to be set on a copy of a DataFrame or Series through chained assignment using an i
    FutureWarning: Setting a value on a view: behaviour will change in pandas 3.0. You are mutating a Series or DataFra
numpy_write
    no warnings
clean_loc
    no warnings

pandas 3.1.0 prints:

pandas 3.1.0
setting and reading mode.copy_on_write:
    Pandas4Warning: The 'mode.copy_on_write' option is deprecated. Copy-on-Write can no longer be disabled (it is always
    Pandas4Warning: The 'mode.copy_on_write' option is deprecated. Copy-on-Write can no longer be disabled (it is always
the option reads back as: warn
view_leak
    no warnings
chained_mask
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Such chained ass
inplace_on_column
    ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment using an inplace
numpy_write
    ValueError: assignment destination is read-only
clean_loc
    no warnings

Reading the results

On pandas 2.3.3 the warn mode caught three of the four risky functions. view_leak got a “Setting a value on a view” warning that says what will change in pandas 3, where the default mode gave only the SettingWithCopyWarning you saw in Step 1. chained_mask got the chained-assignment warning. inplace_on_column got two warnings. But numpy_write printed nothing, even though the guide lists read-only arrays as one of the two cases you must fix. In this run the warn mode did not see a write into an array taken from pandas. And clean_loc, the .loc form from Step 4, was silent, as it should be.

On pandas 3.1.0 the option no longer does anything. The release notes say so: “Setting the option mode.copy_on_write no longer has any impact. The option is deprecated and will be removed in pandas 4.0.” Setting and reading it each raised a Pandas4Warning, and it reads back as warn even though nothing changed. The patterns simply behave under the new rules: view_leak is silent because it was never chained, the two chained patterns raise ChainedAssignmentError, and the array write raises ValueError.

Common mistakes

  • Fixing every warning from the warn mode. The guide says that is not necessary. Fix the chained assignments and the array writes first.
  • Concluding that no warnings means no array writes. In this run it did not.

7b: A pytest gate that turns the warnings into failures

The idea

Warnings are easy to miss, so make them impossible to miss. pytest can turn a warning category into an error with the filterwarnings setting. The 3.1.0 release notes describe the same move for the new warning: it “can be silenced or turned into an error with warnings.filterwarnings”. You will add a pytest.ini that does it, and one test per report step that checks the result, not only the absence of warnings.

# pytest.ini
[pytest]
filterwarnings =
    error::pandas.errors.ChainedAssignmentError
    error::FutureWarning

The first line makes ChainedAssignmentError an error. The second makes every FutureWarning an error, because pandas 2.3.3 reports chained assignment as a FutureWarning. Together the file works on both versions: pandas.errors.ChainedAssignmentError exists in both, and each version raises whichever category it uses.

Now the tests. Each test runs one report step on fresh orders and asserts the outcome. The steps fixture runs every test twice, once against legacy_report and once against the rewritten report.

"""test_report.py: one test per report step, run against the legacy steps and the rewritten ones."""
import importlib

import pytest
from orders import make_orders


@pytest.fixture(params=["legacy_report", "report"])
def steps(request):
    return importlib.import_module(request.param)


def test_discount(steps):
    orders = make_orders()
    steps.discount(orders)
    assert orders["price"].round(2).tolist() == [20.0, 13.95, 99.0, 6.75, 42.0]


def test_cancel(steps):
    orders = make_orders()
    steps.cancel(orders)
    assert orders["qty"].tolist() == [3, 8, 0, 12, 5]


def test_label_coupons(steps):
    orders = make_orders()
    steps.label_coupons(orders)
    assert orders["coupon"].tolist() == ["none", "SAVE5", "none", "none", "SAVE5"]


def test_correct_price(steps):
    orders = make_orders()
    steps.correct_price(orders)
    assert orders["price"].tolist()[4] == 40.0


def test_price_floor(steps):
    orders = make_orders()
    steps.price_floor(orders)
    assert orders["price"].tolist()[3] == 10.0

Run it on both versions

..\pd2\Scripts\python -m pytest -q --tb=no -rf --disable-warnings test_report.py
..\pd3\Scripts\python -m pytest -q --tb=no -rf --disable-warnings test_report.py

The options keep the output short: -q is quiet, --tb=no hides tracebacks, -rf lists the failures, and --disable-warnings hides the warning summary. pandas 2.3.3 prints:

F.F.F.F... [100%]
=== short test summary info ===
FAILED test_report.py::test_discount[legacy_report] - FutureWarning: ChainedAssignmentError: behaviour will change in pandas 3.0!
FAILED test_report.py::test_cancel[legacy_report] - assert [3, 8, 1, 12, 5] == [3, 8, 0, 12, 5]
FAILED test_report.py::test_label_coupons[legacy_report] - FutureWarning: A value is trying to be set on a copy of a DataFrame or Series through ch...
FAILED test_report.py::test_correct_price[legacy_report] - FutureWarning: ChainedAssignmentError: behaviour will change in pandas 3.0!
4 failed, 6 passed, 1 warning in 0.04s

pandas 3.1.0 prints:

F.F.F.F.F. [100%]
=== short test summary info ===
FAILED test_report.py::test_discount[legacy_report] - pandas.errors.ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series...
FAILED test_report.py::test_cancel[legacy_report] - pandas.errors.ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series t...
FAILED test_report.py::test_label_coupons[legacy_report] - pandas.errors.ChainedAssignmentError: A value is being set on a copy of a DataFrame or S...
FAILED test_report.py::test_correct_price[legacy_report] - pandas.errors.ChainedAssignmentError: A value is being set on a copy of a DataFrame or S...
FAILED test_report.py::test_price_floor[legacy_report] - ValueError: assignment destination is read-only
5 failed, 5 passed in 0.02s

On pandas 3.1.0 all five legacy tests fail and all five rewritten tests pass: four failures are ChainedAssignmentError and one is the read-only ValueError. On pandas 2.3.3 four legacy tests fail. Three fail through the warning filter. test_cancel fails on its assertion, because that bug raised only a SettingWithCopyWarning, which the filter does not cover, and only the check on the result noticed that quantity 1 should have become 0. test_price_floor passes on pandas 2.3.3, because the array write works there. That is a reminder that the gate is only complete on pandas 3. All five tests of the rewritten report pass on both versions.

To see what a failure looks like, run one test with a short traceback on pandas 3.1.0:

..\pd3\Scripts\python -m pytest -q --tb=short -k test_discount test_report.py
F. [100%]
=== FAILURES ===
___ test_discount[legacy_report] ___
test_report.py:15: in test_discount
    steps.discount(orders)
legacy_report.py:6: in discount
    orders["price"][orders["qty"] > 5] = orders["price"] * 0.9
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
..\pd3\Lib\site-packages\pandas\core\series.py:1225: in __setitem__
    warnings.warn(
E   pandas.errors.ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.
E   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).
E
E   Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.
E
E   See the documentation for a more detailed explanation: https://pandas.pydata.org/docs/dev/user_guide/migration.html#chained-assignment
=== short test summary info ===
FAILED test_report.py::test_discount[legacy_report] - pandas.errors.ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series...
1 failed, 1 passed, 8 deselected in 0.12s

The traceback names the line in your code, legacy_report.py:6, and the message repeats the fix. The -k test_discount option picked the two test_discount cases, so one failed and one passed.

What the gate cannot see

  • Code that no test executes. A warning appears only when the line runs.
  • False alarms from compiled code before 3.1.0. The 3.1.0 release notes list a fix for a “spurious errors.ChainedAssignmentError warning for a valid assignment made from compiled code”. That is one reason to run the gate on 3.1.0 or newer.

7c: A static scanner for the lines no test reaches

The idea

A static scanner reads source code without running it. Python ships a parser for this in the ast module: ast.parse turns source text into a tree of nodes. The nodes you need are few. A Subscript is anything in square brackets, such as df["price"]. An Attribute is a dotted name such as df.loc. An Assign is a statement with an equals sign, and a Call is a function call.

Chained assignment has a recognizable shape in that tree. The target of the assignment is a subscript whose own value is another subscript: df["price"][mask] = 0.0 is Subscript(Subscript(df, "price"), mask). The .loc form is different, because df.loc is an attribute, not a subscript: df.loc[mask, "price"] is Subscript(Attribute(df, "loc"), ...). The scanner needs one extra step so that df["price"].iloc[0] is still caught. It looks through .loc, .iloc, .at and .iat to see what they are applied to. If that is a subscript, the assignment is chained. The second rule finds inplace=True on a method whose receiver is a selection, like df["coupon"].fillna("none", inplace=True).

The code

Create chainscan.py. Read it from the top: unwrap does the looking-through, the two is_ functions hold the two rules, assignment_targets lists the things a statement assigns to, scan_source walks a whole file, and main prints findings and returns exit code 1 when there are any, so a build can fail on it.

"""chainscan.py: list the lines that look like pandas chained assignment. A heuristic, not a proof."""
import ast
import sys
from pathlib import Path

INDEXERS = {"loc", "iloc", "at", "iat"}
SET_MESSAGE = "CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value"
INPLACE_MESSAGE = "CHAIN002 inplace method on a selection: assign the result back, or call the method on the DataFrame itself"


def unwrap(node):
    """df.loc becomes df; any other expression is returned unchanged."""
    if isinstance(node, ast.Attribute) and node.attr in INDEXERS:
        return node.value
    return node


def is_chained_target(target):
    """True for df["a"][...], df[...]["a"], df.loc[...]["a"] and df["a"].iloc[...]."""
    return isinstance(target, ast.Subscript) and isinstance(unwrap(target.value), ast.Subscript)


def is_inplace_on_selection(call):
    """True for df["a"].fillna(0, inplace=True) and df[mask].drop(columns=["a"], inplace=True)."""
    if not isinstance(call.func, ast.Attribute):
        return False
    wants_inplace = any(
        keyword.arg == "inplace" and isinstance(keyword.value, ast.Constant) and keyword.value.value is True
        for keyword in call.keywords
    )
    return wants_inplace and isinstance(unwrap(call.func.value), ast.Subscript)


def assignment_targets(node):
    if isinstance(node, ast.Assign):
        targets = list(node.targets)
    elif isinstance(node, (ast.AugAssign, ast.AnnAssign)):
        targets = [node.target]
    else:
        return
    while targets:
        target = targets.pop()
        if isinstance(target, (ast.Tuple, ast.List)):
            targets.extend(target.elts)
        else:
            yield target


def scan_source(source, filename="<string>"):
    """Return (filename, line, column, message) for every suspicious statement."""
    found = []
    for node in ast.walk(ast.parse(source, filename=filename)):
        for target in assignment_targets(node):
            if is_chained_target(target):
                found.append((filename, target.lineno, target.col_offset, SET_MESSAGE))
        if isinstance(node, ast.Call) and is_inplace_on_selection(node):
            found.append((filename, node.lineno, node.col_offset, INPLACE_MESSAGE))
    return sorted(found, key=lambda item: (item[1], item[2]))


def scan_path(path):
    path = Path(path)
    files = sorted(path.rglob("*.py")) if path.is_dir() else [path]
    for file in files:
        try:
            yield from scan_source(file.read_text(encoding="utf-8-sig"), str(file))
        except SyntaxError as error:
            yield (str(file), error.lineno or 0, 0, f"CHAIN000 could not parse the file: {error.msg}")


def main(arguments):
    count = 0
    for argument in arguments:
        for filename, line, column, message in scan_path(argument):
            print(f"{filename}:{line}:{column + 1}: {message}")
            count += 1
    print(f"{count} finding(s)")
    return 1 if count else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Run it

..\pd3\Scripts\python chainscan.py legacy_report.py report.py
legacy_report.py:6:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
legacy_report.py:11:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
legacy_report.py:16:5: CHAIN002 inplace method on a selection: assign the result back, or call the method on the DataFrame itself
legacy_report.py:21:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
4 finding(s)

The four findings are exactly the four chained lines of the legacy report, at lines 6, 11, 16 and 21. The rewritten report.py produces none. Now scan the whole folder, as you would scan a source tree:

..\pd3\Scripts\python chainscan.py .
legacy_report.py:6:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
legacy_report.py:11:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
legacy_report.py:16:5: CHAIN002 inplace method on a selection: assign the result back, or call the method on the DataFrame itself
legacy_report.py:21:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
step3_chained.py:7:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
step3_chained.py:11:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
step3_chained.py:15:5: CHAIN002 inplace method on a selection: assign the result back, or call the method on the DataFrame itself
step3_chained.py:19:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
step7_warn_mode.py:20:5: CHAIN001 chained assignment: change the DataFrame in one step, for example df.loc[rows, column] = value
step7_warn_mode.py:24:5: CHAIN002 inplace method on a selection: assign the result back, or call the method on the DataFrame itself
10 finding(s)

The extra six findings are the deliberate chained examples in step3_chained.py and step7_warn_mode.py. Note what is missing: step1_view_leak.py and the array write in step7_warn_mode.py are not listed. The scanner cannot see them, and the next part shows why.

Test the scanner, then test the tests

A checker that nobody has checked is a hazard, so give it tests. The file below lists eight chained shapes that must be flagged and ten safe shapes that must not be, and then the scanner’s known limits written as tests.

"""test_chainscan.py: the scanner flags the chained shapes and leaves the safe rewrites alone."""
import pytest
from chainscan import INPLACE_MESSAGE, SET_MESSAGE, main, scan_path, scan_source

CHAINED = [
    'df["a"][df["b"] > 1] = 0',
    'df[df["b"] > 1]["a"] = 0',
    'df.loc[df["b"] > 1]["a"] = 0',
    'df["a"].iloc[0] = 0',
    'df["a"].loc[df["b"] > 1] += 1',
    'df["a"].fillna(0, inplace=True)',
    'df[df["b"] > 1].drop(columns=["a"], inplace=True)',
    'df.loc[df["b"] > 1, "a"].replace(1, 2, inplace=True)',
]

SAFE = [
    'df.loc[df["b"] > 1, "a"] = 0',
    'df.iloc[0, df.columns.get_loc("a")] = 0',
    'df["a"] = df["a"].fillna(0)',
    'df.fillna({"a": 0}, inplace=True)',
    'df.loc[df["b"] > 1, "a"] += 1',
    'df.at[3, "a"] = 0',
    'x = df["a"]',
    'values[values < 10] = 10',
    'df["a"].fillna(0)',
    'df["a"].fillna(0, inplace=False)',
]


@pytest.mark.parametrize("source", CHAINED)
def test_chained_shapes_are_flagged(source):
    assert len(scan_source(source)) == 1


@pytest.mark.parametrize("source", SAFE)
def test_safe_shapes_are_not_flagged(source):
    assert scan_source(source) == []


def test_every_target_of_a_tuple_assignment_is_checked():
    assert len(scan_source('df["a"][0], df["b"][0] = 1, 2')) == 2


def test_messages_name_the_rule():
    assert scan_source('df["a"][0] = 1')[0][3] == SET_MESSAGE
    assert scan_source('df["a"].fillna(0, inplace=True)')[0][3] == INPLACE_MESSAGE


def test_line_and_column_are_reported():
    source = "import pandas as pd\n\ndf = pd.DataFrame()\ndf['a'][0] = 1\n"
    assert scan_source(source) == [("<string>", 4, 0, SET_MESSAGE)]


def test_directory_scan_and_exit_code(tmp_path, capsys):
    (tmp_path / "bad.py").write_text('df["a"][0] = 1\n', encoding="utf-8")
    (tmp_path / "good.py").write_text('df.loc[0, "a"] = 1\n', encoding="utf-8")
    assert main([str(tmp_path)]) == 1
    output = capsys.readouterr().out
    assert "bad.py:1:1: CHAIN001" in output
    assert "good.py" not in output
    assert main([str(tmp_path / "good.py")]) == 0


def test_syntax_error_is_reported_not_raised(tmp_path):
    broken = tmp_path / "broken.py"
    broken.write_text("def oops(:\n", encoding="utf-8")
    findings = list(scan_path(broken))
    assert len(findings) == 1
    assert findings[0][3].startswith("CHAIN000")


def test_known_false_positive_for_nested_dicts():
    # A dict of dicts has the same shape as a chained pandas assignment, so the scanner flags it.
    assert len(scan_source('config["db"]["host"] = "localhost"')) == 1


@pytest.mark.parametrize(
    "source",
    [
        'df.price[df.qty > 5] = 0',
        'price = df["price"]; price.iloc[0] = 0',
        'values = df["price"].to_numpy(); values[0] = 0',
    ],
)
def test_known_blind_spots(source):
    assert scan_source(source) == []
..\pd3\Scripts\python -m pytest -q test_chainscan.py
........................... [100%]
27 passed in 0.05s

Passing tests only prove the tests agree with the code. To find out whether they would notice a bug, break the scanner on purpose. mutants.py makes five one-line changes to a temporary copy of chainscan.py, runs the tests against each copy, and reports whether any test failed.

"""mutants.py: break chainscan.py on purpose, five ways, and check that the tests notice every time."""
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

HERE = Path(__file__).resolve().parent
MUTANTS = [
    ("scanner does not look through .loc and .iloc",
     "isinstance(unwrap(target.value), ast.Subscript)", "isinstance(target.value, ast.Subscript)"),
    ("indexer list is empty", 'INDEXERS = {"loc", "iloc", "at", "iat"}', "INDEXERS = set()"),
    ("any method call on a selection is flagged",
     "return wants_inplace and isinstance(unwrap(call.func.value), ast.Subscript)",
     "return isinstance(unwrap(call.func.value), ast.Subscript)"),
    ("inplace=False is flagged too", "keyword.value.value is True", "keyword.value.value is not None"),
    ("augmented assignments are skipped", "(ast.AugAssign, ast.AnnAssign)", "(ast.AnnAssign,)"),
]

caught = 0
for label, old, new in MUTANTS:
    with tempfile.TemporaryDirectory() as folder:
        folder = Path(folder)
        source = (HERE / "chainscan.py").read_text(encoding="utf-8")
        assert source.count(old) == 1, label
        (folder / "chainscan.py").write_text(source.replace(old, new), encoding="utf-8")
        shutil.copy(HERE / "test_chainscan.py", folder / "test_chainscan.py")
        result = subprocess.run(
            [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", "test_chainscan.py"],
            cwd=folder, capture_output=True, text=True,
        )
        failed = re.search(r"(\d+) failed", result.stdout)
        count = int(failed.group(1)) if failed else 0
        caught += count > 0
        print(f"{'caught' if count else 'MISSED':<7}{count:>3} failing tests  {label}")
print(f"{caught} of {len(MUTANTS)} mutants caught")
..\pd3\Scripts\python mutants.py
caught   2 failing tests  scanner does not look through .loc and .iloc
caught   2 failing tests  indexer list is empty
caught   4 failing tests  any method call on a selection is flagged
caught   1 failing tests  inplace=False is flagged too
caught   1 failing tests  augmented assignments are skipped
5 of 5 mutants caught

All five broken versions were caught. A mutant that survived would mean a test is missing.

The scanner’s limits

The last tests in the file record what the scanner gets wrong, so the limits are checked and not just remembered:

  • A false positive. config["db"]["host"] = "localhost" is a dictionary of dictionaries, not pandas, but it has the same shape, so it is flagged. A scanner cannot know the type of config.
  • Column access by attribute. df.price[df.qty > 5] = 0 is chained assignment, but price is not one of the four indexers, so it is not flagged.
  • A hop through a variable. price = df["price"] followed by price.iloc[0] = 0 is the Step 1 leak. The two statements are separate, and finding the connection needs data-flow analysis.
  • A write into an array. values = df["price"].to_numpy() followed by values[0] = 0 has the same problem.

The test gate covers some of these at run time. In a quick test on pandas 3.1.0, orders.price[orders.qty > 5] = 0.0 raised a ChainedAssignmentError and changed 0 rows, and the array write raises ValueError. The hop through a variable gives no warning at all, so only a test that checks the result notices the lost update, which is why the tests in Step 7b assert values. The dictionary false positive is something a reviewer resolves in seconds. The three tools cover each other, and none of them is enough alone.

What Ruff covers

You may already run Ruff, which has a set of pandas rules (the PD family). Install it into the pandas 3 environment and run those rules over two files:

..\pd3\Scripts\python -m pip install ruff==0.17.3
..\pd3\Scripts\python -m ruff check --select PD --output-format concise legacy_report.py step3_chained.py
step3_chained.py:15:37: PD002 `inplace=True` should be avoided; it has inconsistent behavior
Found 1 error.
1 unsafe fix available with `--fix --unsafe-fixes`.

Ruff flagged one line, the inplace=True in step3_chained.py, and nothing in legacy_report.py, which holds the same inplace=True call. The difference is that step3_chained.py imports pandas and legacy_report.py does not, because it receives its DataFrame as an argument. When I added import pandas as pd to a copy of legacy_report.py, Ruff flagged its inplace=True line as well. So in this test the rule fired only in files that import pandas, and helper modules that only receive DataFrames escape it. The rule, PD002, is a style rule: its documentation says “Using inplace=True encourages mutation rather than immutable data, which is harder to reason about and may cause bugs.” It says nothing about Copy-on-Write. I also searched the name, summary and explanation of every rule in Ruff 0.17.3 (973 rules) for “chained assignment”, “SettingWithCopy” and “Copy-on-Write” and found no match. For this migration, Ruff does not replace the scanner or the gate.

Step 8: Confirm the migration end to end

The final check

The proof that the migration worked is the comparison from Step 4: the rewritten report gives the same output on both versions, and the gate passes on both. Run both once more, together:

..\pd3\Scripts\python compare.py report
..\pd2\Scripts\python -m pytest -q --tb=no -rf --disable-warnings test_report.py
..\pd3\Scripts\python -m pytest -q --tb=no -rf --disable-warnings test_report.py

The comparison must still show only the version header as changed. The two pytest runs fail only for legacy_report, which is kept in the lab as the “before” picture. In a real project you would delete the legacy module after the rewrite, and the gate would pass with no failures.

A migration checklist for your own project

  1. Upgrade to pandas 2.3 first and read its warnings, as the release notes advise.
  2. Add the pytest gate (error::FutureWarning and error::pandas.errors.ChainedAssignmentError) and fix what fails.
  3. Run the scanner over the source tree and review every finding, remembering the false positives.
  4. Search for array writes: to_numpy() and .values followed by an assignment, and helper functions that write into the arrays they receive.
  5. Search for the old silencers such as mode.chained_assignment and remove them. Remove defensive .copy() calls that existed only for SettingWithCopyWarning.
  6. Run your real reports on both versions and diff the outputs, the way compare.py does. The diff is your proof.
  7. Upgrade to pandas 3.1 in a separate environment, run the gate there, and keep it on.

One more default that changed

Copy-on-Write is not the only breaking change in pandas 3. The guide has a second large section on the new default string type. Here is a taste of it, so that the next surprise is not a surprise:

"""step8_dtype.py: one more default that changed in pandas 3."""
import pandas as pd

print("pandas", pd.__version__)
print("dtype of a text column:", pd.Series(["a", "b"]).dtype)
print("a missing text value  :", pd.Series(["a", None]).iloc[1])
..\pd2\Scripts\python step8_dtype.py
..\pd3\Scripts\python step8_dtype.py

pandas 2.3.3 prints:

pandas 2.3.3
dtype of a text column: object
a missing text value  : None

pandas 3.1.0 prints:

pandas 3.1.0
dtype of a text column: str
a missing text value  : nan

A text column is now str instead of object, and a missing text value is nan instead of None. You saw the second one already in Step 2, where the coupon column printed nan. Code that checks dtype == object or compares a missing value with None needs the same kind of review. The migration guide covers it under “The new string data type”.

What to remember

  • In pandas 3 every object you get from another object behaves as a copy. The only way to change a DataFrame is to change that DataFrame, so select rows and column in one .loc or .iloc step.
  • The danger is silent: four of the five report steps did nothing and raised no exception, and only the fifth step, the array write, failed loudly.
  • Rewrites such as .loc and fillna with a dictionary behave identically on pandas 2.3.3 and 3.1.0, so you can migrate in two safe steps.
  • Arrays from to_numpy() are read-only, and the Series constructor copies the array you give it.
  • Lazy copies make most operations nearly free, but one spare reference to the original brings back a copy at the first write. Reassign instead of keeping both.
  • Use the three nets together: pandas 2.3 warnings, a pytest gate that fails on ChainedAssignmentError, and a scanner for the lines no test runs.

Sources and further reading

  • pandas, Migration guides: Copy-on-Write and the new string data type.
  • pandas, What’s new in 3.0.0 (January 21, 2026) and What’s new in 3.1.0 (October 22, 2026).
  • pandas, pandas.errors.ChainedAssignmentError.
  • pandas 2.3.3, Copy-on-Write (CoW) user guide, which describes the warn mode.
  • pandas, PDEP-7: Consistent copy/view semantics in pandas with Copy-on-Write and PDEP-14: Dedicated string data type for pandas 3.0, the design documents behind the two big changes.
  • Ruff, pandas-use-of-inplace-argument (PD002).
  • PyPI, pandas release history.
  • More from this site: building an idempotent, incremental ETL pipeline with pandas is a good place to apply the checklist above, and property-based testing with Hypothesis shows another way to find bugs that example tests miss.

Tags:

Copy-on-WriteData EngineeringPandaspytestPythonStatic Analysis

Share

A hand drops a ballot envelope into a ballot box sealed with red tamper-evident tape marked VOID OPENED
Previous Post

Trail of Bits’ E-Voting Primer Turns Verifiability Into Four Settings, and the Public Records Show Helios Hashing Only Proof Commitments, 8 Audits Posted for 1,490 IACR Ballots, and an ElectionGuard Pilot Run Without Chaining

A white wooden window with its glass panes out, framed by two scaffolding poles, a visual pun on a software update window and its staging area
Next Post

CISA Lists the WordPress Update-Window RCE as Exploited Six Days After the Patch, but 497 of the 502 Partial Update Packages for the Fix Omit the Installer the Attack Targets

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
06 Nov
How to Secure a WebSocket Endpoint in FastAPI and Stop Cross-Site Hijacking, Leaked Tokens, Floods, and Slow Readers
06 Nov
OpenAI’s Astra Report Turns Silent Safety-Task Underperformance Into a Matched-Pair Gap, but the Ethical-Doubt Result’s Bar Runs From a 4-Point Gap to a 3.6-Point Reversal, and the Launch Card Quoted AISI as Seeing Very Few Refusals
Trending
November 6, 2026
How to Secure a WebSocket Endpoint in FastAPI and Stop Cross-Site Hijacking, Leaked Tokens, Floods, and Slow Readers
November 6, 2026
OpenAI’s Astra Report Turns Silent Safety-Task Underperformance Into a Matched-Pair Gap, but the Ethical-Doubt Result’s Bar Runs From a 4-Point Gap to a 3.6-Point Reversal, and the Launch Card Quoted AISI as Seeing Very Few Refusals
November 6, 2026
OpenAI Releases GPT-6 Cyber, Rated Critical, to Approved Customers: the Post’s 99.93% Scope Score Is Measured Against the Older Cyber Model, the System Card Puts Its Base Model at 0% Honeypot Exploitation, and One Outside Benchmark Has It Solving Fewer Challenges
November 5, 2026
How to Spread Requests Across Servers in Python With the Power of Two Choices and Avoid the Herd Effect of Least-Loaded Routing
November 5, 2026
Microsoft’s PDD Kit Turns Code Review Into Intent Review, but Verus Accepts a Login That Answers Every Attempt With a 500 Under the Contract the Post Prints
November 5, 2026
OpenSSH Patches a Password Bypass on musl Systems Such as Alpine, Where a 257 to 1,024 Byte Password Logs In to Password-Disabled Accounts, and musl’s Maintainer Says the Bug Is Not in musl

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026