If your notebooks ran fine on pandas 2.x and you are about to run pip install --upgrade pandas, wait. pandas 3.0 arrived on 21 January 2026, and it removes some habits that used to work "most of the time". Here are the six changes that break real analysis code, with the old pattern and the safe one next to each.
First, the upgrade path from the pandas team: move to pandas 2.3, make your code run without warnings, and only then go to 3.0. The pandas 3.0 announcement says exactly that. The minimum versions also moved: Python 3.11 and NumPy 1.26.0, according to the 3.0.0 release notes.
1. Chained assignment no longer updates your data
This is the one most likely to bite you. A pattern like df[df["a"] > 0]["b"] = 99 used to modify the original sometimes, with a warning. Under Copy-on-Write, a subset always behaves as a copy, so the original is not changed. The pandas user guide says a related pattern now raises a ChainedAssignmentError.
df["foo"][df["bar"] > 5] = 100 # breaks
df.loc[df["bar"] > 5, "foo"] = 100 # works
The same logic hits inplace=True on a single column. According to the guide, df["foo"].replace(1, 5, inplace=True) does not modify df. Either call it on the whole frame or assign the result back: df["foo"] = df["foo"].replace(1, 5).
One side effect: defensive .copy() calls are now unnecessary, and the SettingWithCopyWarning is gone.
2. Text columns are no longer "object"
Strings now get a dedicated str dtype instead of object. Any code that checks ser.dtype == object to find text columns will quietly return False.
old = [c for c in df.columns if df[c].dtype == object] # finds nothing now
new = [c for c in df.columns if df[c].dtype == "str"] # finds text columns
The new dtype holds only strings or missing values, and the missing marker is always NaN. If PyArrow is installed it backs the column; otherwise pandas falls back to NumPy objects. The pandas team strongly recommends installing PyArrow, but it is not installed by default.
3. Datetimes no longer default to nanoseconds
Parsing a string like "2024-03-22 11:36" now gives datetime64[us], microseconds, not datetime64[ns]. That is useful because dates before 1678 or after 2262 no longer overflow. It also means integer conversions change by a factor of 1,000.
ts = pd.to_datetime(["2024-01-01"])
ts.astype("int64") # microseconds now: 1000x smaller
ts.as_unit("ns").astype("int64") # explicit, same numbers as before
The release notes' advice is to avoid astype("int64") for dates and to state the unit with as_unit() first. Check any feature engineering that turns timestamps into numbers, and any merge with data stored as nanosecond integers.
4. Old frequency aliases are gone
Aliases deprecated in earlier versions are removed. If you resample or build date ranges with month, quarter or year ends, update them.
"M"becomes"ME""Q"becomes"QE""Y"becomes"YE"
A df.resample("M") that ran on 2.3 with a warning will fail on 3.0. This is the reason for the "fix warnings on 2.3 first" advice.
5. to_numpy() can hand you a read-only array
If the array shares memory with the DataFrame, pandas now returns it read-only. Writing to it raises ValueError: assignment destination is read-only. This shows up in code that pulls out an array, edits it in place and expects the frame to follow, or not follow.
arr = df.to_numpy().copy() # copy first if you plan to edit
arr[0, 0] = 100
The user guide also notes that the Series and DataFrame constructors now copy NumPy arrays by default. Pass copy=False if you really need to share memory.
6. Time zones and inplace return values changed
Two smaller changes that still break tests. Time zones now use the standard library zoneinfo instead of pytz, and pytz is no longer a required dependency. A check such as isinstance(ts.tz, pytz.tzinfo.BaseTzInfo) needs to become a ZoneInfo check, and ambiguous or nonexistent local times now raise ValueError.
Also, methods like fillna(inplace=True) now return the modified object instead of None. If a script relies on the None, it will break in a confusing way.
One habit worth picking up
The Copy-on-Write guide has a small performance tip. When you no longer need the original object, assign the result back to the same name. If you keep both, they share data, and the first edit to either one triggers a copy.
df = df.reset_index(drop=True) # no copy needed on the next edit
df.iloc[0, 0] = 100
Compare that with df2 = df.reset_index(drop=True) followed by an edit to df2, where the guide says pandas has to copy because df still shares the data. On a large frame, this is the difference between an instant edit and a duplicated block of memory. It costs nothing to adopt.
A short migration routine
Install pandas 2.3 in a fresh environment and run your tests with warnings turned into errors, for example
python -W error::FutureWarning -m pytest.Search your code for chained brackets before an assignment,
== object,astype("int64")on dates and the old aliases.Upgrade in a separate environment, run the same tests, then compare a few key outputs, such as row counts, null counts and one summary table.
Until that comparison passes, pin
pandas<3in anything that runs on a schedule, so a stray upgrade does not quietly change your numbers.
The most common mistake is trusting that "no error" means "same answer". Chained assignment under Copy-on-Write does not always shout. Compare results, not just exit codes. Then move one pipeline at a time.
