Guide, 10 min read, updated 13 October 2026

Upgrading to pandas 3.0: what changed, what breaks and how to fix it

The biggest pandas release in years changes how strings, copies and dates work. Here is what breaks, how to fix it, and the new features worth using.

PythonWhat's new
All guides and cheat sheets

pandas 3.0 was released in January 2026, and most data teams are now planning the move from 2.x. It is the biggest change to pandas in years: strings get their own data type, Copy-on-Write becomes the only behaviour, and dates default to a different precision. This guide covers what changed, the code that is most likely to break, and a safe way to upgrade.

Before you start: upgrade in two steps

The pandas team's own advice is to go through 2.3 first. Version 2.3 warns about almost everything that changes in 3.0, so you can fix your code while it still runs.

Terminal
pip install "pandas==2.3.*"
python -W error::FutureWarning -m pytest      # turn warnings into failures
# fix everything that fails, then:
pip install "pandas==3.0.*" pyarrow

pandas 3.0 needs Python 3.11 or newer and NumPy 1.26 or newer. PyArrow is optional but strongly recommended, because it makes the new string type much faster.

1. Strings have their own dtype

Text columns used to be stored as the generic object type. They are now inferred as a dedicated str dtype.

Python
ser = pd.Series(["London", "Milan"])
ser.dtype
# pandas 2.x: dtype('O')   (object)
# pandas 3.0: str

What breaks: code that checks df[col].dtype == object to find text columns will silently skip them. Use the type helpers instead:

Python
# Before
text_cols = [c for c in df.columns if df[c].dtype == object]

# After: works in pandas 2.x and 3.0
text_cols = [c for c in df.columns if pd.api.types.is_string_dtype(df[c])]

A str column can only hold text or missing values, so putting a number into one now raises an error instead of quietly creating a mixed column. Missing values in it are always NaN.

2. Copy-on-Write is the only behaviour

Every result of selecting or filtering now behaves like a copy. Changing it never changes the original DataFrame by accident, and the infamous SettingWithCopyWarning is gone.

What breaks: chained assignment, where you select twice and then assign, no longer changes anything.

Python
# Silently does nothing in pandas 3.0
df["discount"][df["units"] > 10] = 0.1

# Do it in one step with .loc
df.loc[df["units"] > 10, "discount"] = 0.1

The good news: you can delete all the defensive .copy() calls you added to silence warnings, and pandas avoids making real copies until something actually changes, which often saves memory.

3. Dates default to microseconds, not nanoseconds

pandas now picks the precision from the input. Parsing ordinary date strings gives microseconds; integers in seconds stay in seconds.

Python
pd.to_datetime(["2026-10-13 09:30"]).dtype      # datetime64[us]
pd.to_datetime([0], unit="s").dtype              # datetime64[s]

This removes the old limit where dates before 1677 or after 2262 caused errors. What breaks: code that converts dates to integers with astype("int64") now gets numbers 1,000 times smaller. Set the unit explicitly first:

Python
epoch_ns = df["created_at"].dt.as_unit("ns").astype("int64")

4. Other changes that can catch you out

  • Time zones now use Python's built-in zoneinfo instead of pytz. Ambiguous or non-existent times around clock changes raise an error rather than guessing.
  • pd.offsets.Day now means a calendar day, keeping the same clock time across daylight-saving changes.
  • Nullable and Arrow types: calculations that used to produce NaN, such as dividing by zero, can now produce NA.
  • value_counts(sort=False) keeps the original order instead of sorting by label.
  • Functionality deprecated in 2.x has been removed, so any warning you ignored in 2.3 becomes an error in 3.0.

The new features worth using

pd.col: expressions without lambdas

pd.col() refers to a column by name, which makes method chains much easier to read.

Python
# Before
df = df.assign(revenue=lambda d: d["units"] * d["unit_price"])

# pandas 3.0
df = df.assign(revenue=pd.col("units") * pd.col("unit_price"))
big_orders = df.loc[pd.col("revenue") > 1000]

Anti joins in one line

Finding rows in one table with no match in another, such as customers who never ordered, used to need a merge, an indicator column and a filter. Now it is a join type:

Python
never_ordered = customers.merge(orders, on="customer_id", how="left_anti")

Arrow and Iceberg

  • DataFrame.from_arrow() and the Arrow PyCapsule interface move data efficiently to and from Arrow-based tools such as DuckDB and Polars.
  • pd.read_iceberg() and DataFrame.to_iceberg() read and write Apache Iceberg tables directly.
  • to_excel() gains an autofilter option, so exported reports open with filters already on.

A quick upgrade checklist

  • Upgrade to 2.3 and fix every FutureWarning and DeprecationWarning.
  • Search your code for == object, dtype("O") and "object" used to detect text.
  • Search for chained assignment such as df["a"][mask] = and rewrite it with .loc.
  • Search for astype("int64") on dates and add .dt.as_unit("ns") where you need nanoseconds.
  • Install pyarrow, upgrade to 3.0 in a branch, and compare a few key outputs with the old version before switching production.

New to pandas? Start with pandas for Excel users, and keep the pandas cheat sheet open while you work. To check your cleaned data before and after an upgrade, Clean & Compare can show exactly which rows and cells changed.

Source: the official pandas 3.0.0 release notes and the pandas 3.0 release announcement. Check the full "Backwards incompatible API changes" section before upgrading production code.

Alessandro Ecclesie AgazziWritten by Alessandro Ecclesie Agazzi, analytics engineer in London. Updated 13 October 2026. About me