Skip to content

pandas 3.1 Upgrade Guide: Fix the inplace Pandas4Warning, Use df.select() and pd.col(), and Read CSVs Faster

Updated on

pandas 3.1.0 (October 22, 2026) deprecates inplace=True on drop, rename, sort_values and eight other methods, adds DataFrame.select() with pd.col() expressions and filter(cond=), and reads large CSVs in parallel. Tested on macOS: a 72 MB CSV went from 0.23 s to 0.02 s, the exact Pandas4Warning list, the read_csv options that silently turn the parallel reader off, and the NumPy 2.3.3 trap on Python 3.14.

The short answer: pandas 3.1.0 was released on October 22, 2026, and the 3.1.1 bug-fix release followed on November 4. Upgrade straight to 3.1.1:

pip install --upgrade "pandas>=3.1"
# or, in a uv project
uv add "pandas>=3.1"

Three things will happen after the upgrade:

  1. Your code prints Pandas4Warning for every inplace=True on drop, rename, reset_index, set_index, sort_values, dropna, drop_duplicates and a few more. The fix is mechanical: df = df.drop(...) instead of df.drop(..., inplace=True). Often the warning comes from a library you use, not from your code.
  2. pd.read_csv on large local files gets much faster. A 72 MB file went from 0.23 s on pandas 3.0.1 to 0.02 s on 3.1 in our test. Some common arguments switch the speedup off, and they are listed below.
  3. You get a Polars-style API: df.select("a", pd.col("b") * 2) and df.filter(pd.col("a") > 1).

If you are still on pandas 2.x, the jump to 3.x (Copy-on-Write, the default string dtype) is the bigger change, and the pandas 3.0 migration guide (opens in a new tab) is the place to start. This guide covers 3.0 → 3.1 and the traps we hit.

What was tested: every item marked "tested" was run on November 1, 2026, on an Apple M4 Max (16 cores, macOS) in a fresh uv virtual environment with CPython 3.14.2, pandas 3.1.0, NumPy 2.5.4 and PyArrow 26.0.0. The comparison numbers come from pandas 3.0.1 with NumPy 2.4.2 and no PyArrow on the same machine. Windows, Linux, conda and Docker behavior come from the official docs and were not tested here. On November 4, 2026, every tested claim was re-run on pandas 3.1.1 with the same results.

What changed in 3.1 that you will notice

ChangeWhat it means for youAction
inplace deprecated on 11 methods (PDEP-8)Each call emits a Pandas4Warning; it will be removed in pandas 4.0Reassign the result: df = df.drop(...)
Parallel read_csv for local, uncompressed files ≥ 5 MB2–3x faster single-threaded, plus up to 6 threads by defaultNothing, unless you pass usecols, index_col, nrows or a file handle (see below)
DataFrame.select() (takes pd.col() expressions, available since 3.0)Pick and compute columns in one call, Polars styleOptional; useful in method chains
filter(cond=...) accepts a boolean maskdf.filter(pd.col("a") > 1) keeps rowsWatch out: a positional list of booleans still selects labels
NumPy ≥ 2.0.2 required (≥ 2.3.3 on Python 3.14)NumPy 1.x environments cannot install 3.1Upgrade NumPy first, or pin pandas<3.1
Numeric input defaults to microsecondspd.to_timedelta([1], unit="s") is now timedelta64[us], not [s]Fixes a 3.0 rounding bug; use .as_unit() if you need a specific resolution
dtype="category" in read_csv infers numeric categoriesZip codes and IDs lose leading zerosRead those columns as str first

The full list is in the official What's new in 3.1.0 (opens in a new tab). The rest of this guide walks through the tasks in order: install, fix the warnings, use the new API, speed up CSV loading, and check the behavior changes that can alter results without an error.

Trending reads

Step 1: Install pandas 3.1

Requirements (tested)

pandas 3.1.0 ships wheels for CPython 3.11 through 3.15 (requires_python >= 3.11) and is on both PyPI and conda-forge. The NumPy floor depends on your Python version:

PythonMinimum NumPy
3.11 – 3.132.0.2
3.14 – 3.152.3.3

The whatsnew page only mentions 2.0.2. On Python 3.14 the wheel metadata asks for 2.3.3, which is what the resolver enforces. Pinning NumPy 1.x fails outright:

  × No solution found when resolving dependencies:
  ╰─▶ Because pandas==3.1.1 depends on numpy>=2.3.3 and you require
      pandas==3.1.1, we can conclude that you require numpy>=2.3.3.
      And because you require numpy<2, we can conclude that your requirements
      are unsatisfiable.

If a dependency still pins numpy<2, that dependency blocks the upgrade, not pandas. Either update it or keep pandas<3.1 until it does.

Upgrade (tested with uv)

Install 3.1.1, not 3.1.0: 3.1.1 fixes a regression where read_csv and to_csv treated path objects such as pytest's tmpdir as open file handles and could raise or write incomplete files (What's new in 3.1.1 (opens in a new tab)).

# pip
pip install --upgrade "pandas>=3.1,<3.2"
 
# uv project
uv add "pandas>=3.1,<3.2"
 
# uv, throwaway environment to test first
uv venv p31 --python 3.14
uv pip install --python p31/bin/python "pandas==3.1.1" pyarrow
p31/bin/python -c "import pandas as pd; print(pd.__version__)"

conda-forge has 3.1.1 (checked November 4, 2026; conda install -c conda-forge "pandas>=3.1"), not tested here.

Install PyArrow as well if you have not. pandas 3 uses it for the default string dtype when it is present, and the engine="pyarrow" CSV reader needs it.

Step 2: Fix the Pandas4Warning for inplace

After the upgrade, code like this:

df.drop(columns=["rain"], inplace=True)

prints:

Pandas4Warning: The inplace keyword in DataFrame.drop is deprecated and will be removed in a future version (PDEP-8).
See https://pandas.pydata.org/docs/dev/whatsnew/v3.1.0.html#deprecation-inplace for more details.

Which methods are affected (tested)

The deprecation covers methods where inplace=True never changed values in place. For dropna, sort_values and the other row-reordering methods it was always a reassignment, and the copy that drop, rename or set_index used to avoid no longer happens anyway under Copy-on-Write, which landed in pandas 3.0.

Methodinplace in 3.1Fix
drop, rename, rename_axisDeprecateddf = df.drop(...)
reset_index, set_indexDeprecateddf = df.reset_index()
sort_values, sort_indexDeprecateddf = df.sort_values("col")
dropna, drop_duplicatesDeprecateddf = df.dropna()
eval, queryDeprecateddf = df.query("a > 1")
fillna, replace, where, mask, clip, interpolateKept (they can update values in place)No change needed

In our test, all 11 deprecated methods warned when called with inplace=True; fillna and replace did not, as documented.

The replacement is a one-line change per call:

# before
df.rename(columns={"temp": "temp_c"}, inplace=True)
df.sort_values("temp_c", inplace=True)
df.reset_index(drop=True, inplace=True)
 
# after (same cost, chains cleanly)
df = (
    df.rename(columns={"temp": "temp_c"})
      .sort_values("temp_c")
      .reset_index(drop=True)
)

Find every call in your codebase (tested)

Turn the warning into an error for one run and the traceback points at the exact line. python -W does not accept a third-party class such as pandas.errors.Pandas4Warning: it prints Invalid -W option ignored: invalid module name: 'pandas.errors' and runs the script anyway. Match the message instead:

python -W "error:The inplace keyword:DeprecationWarning" your_script.py

To turn every Pandas4Warning into an error, not only the inplace ones:

python -c "import runpy, warnings, pandas as pd; warnings.simplefilter('error', pd.errors.Pandas4Warning); runpy.run_path('your_script.py', run_name='__main__')"

Both exit with a traceback at the first inplace line. The message filter leaves unrelated deprecation warnings alone.

For a test suite, pytest applies its -W filters after pandas is importable, so the class name works there:

pytest -W error::pandas.errors.Pandas4Warning

Pandas4Warning is a subclass of PandasDeprecationWarning, so a filter on either works. A grep gets you most of the way too:

grep -rn "inplace=True" --include="*.py" --include="*.ipynb" .

When the warning comes from a library, not from you

This is the common case in the first weeks after a release. Between October 22 and November 1, 2026, GitHub gained about 300 issues and pull requests mentioning Pandas4Warning and about 3,500 mentioning "pandas 3.1", in projects such as yfinance (29 inplace calls across 8 files, yfinance #3009 (opens in a new tab); fixed in yfinance 1.8.0 on November 3), FLAML, marimo, cuDF, darts and category_encoders.

You cannot fix those from your code. Options, in order of preference:

  1. Update the library. Many shipped fixes within days.
  2. Silence this one warning class until the library catches up, instead of all warnings:
import warnings
import pandas as pd
 
warnings.filterwarnings("ignore", category=pd.errors.Pandas4Warning)
  1. Pin pandas<3.1 in the environment that runs the unmaintained library. Do not pin pandas 3.0 forever; the inplace keyword will be removed in 4.0 and the warning is your notice period.

Other deprecations you may hit in 3.1 (tested unless noted)

Code that warns nowReplacement
s.dt.dayofweek, .dayofyear, .daysinmonths.dt.day_of_week, .day_of_year, .days_in_month
df.select_dtypes(include="object") on string columns (already warned in 3.0; 3.1 adds exclude="object")Pass "str" explicitly; "object" no longer means "text" in pandas 3
s.fillna("missing") on a float columnCast to a common dtype first; this will raise in a future version
ser.values on a timezone-aware columnser.array, ser.to_numpy(), or ser.dt.tz_convert(None).to_numpy() to keep the old UTC array
pd.infer_freq(idx) returning "D"Will return an offset object; use .freqstr or set future.infer_freq_returns_offset
read_parquet(engine="fastparquet") (docs)fastparquet is retired; use the default PyArrow engine
pd.set_eng_float_format() (docs)pd.set_option("display.precision", n)

Step 3: Use df.select(), pd.col() and filter(cond=)

DataFrame.select() and filter(cond=) are the visible new API in 3.1. pd.col() itself arrived in pandas 3.0 for assign() and .loc; in 3.1 it becomes the natural argument to both new methods. If you have used Polars, they will look familiar.

select picks and computes columns in one call (tested)

import pandas as pd
 
df = pd.DataFrame({
    "city": ["Tokyo", "Seoul", "Osaka"],
    "temp": [18.2, 15.1, 19.5],
    "rain": [12, 30, 8],
})
 
df.select("city", "temp")                         # same as df[["city", "temp"]]
df.select("city", temp_f=pd.col("temp") * 1.8 + 32)   # keyword names the new column
    city  temp_f
0  Tokyo   64.76
1  Seoul   59.18
2  Osaka   67.10

Later arguments can refer to columns created earlier in the same call, like assign:

df.select(temp_c=pd.col("temp"), temp_f=pd.col("temp_c") * 1.8 + 32)

pd.col("temp") is a pandas.api.typing.Expression. It supports the operators and accessors you would use on a Series, including .str and .round():

df.select(pd.col("city").str.upper(), pd.col("temp").round(0))

Three things that tripped us up (tested):

  • There is no .alias(). Name a computed column with the keyword form (temp_f=...). pd.col("temp").alias("x") builds without complaint, then df.select(...) fails with AttributeError: 'Series' object has no attribute 'alias'.
  • An expression that reduces to a scalar, like pd.col("temp").mean(), must be passed as a keyword (mean_temp=pd.col("temp").mean()); it is then broadcast to every row. Passed positionally it raises TypeError.
  • A missing column raises KeyError, the same as df[["nope"]].

filter(cond=) keeps rows by a boolean mask (tested)

DataFrame.filter used to select labels only (items=, like=, regex=). In 3.1 it also takes a mask:

df.filter(cond=df["temp"] > 17)          # a boolean Series
df.filter(pd.col("temp") > 17)           # an expression, positional is fine
df.filter(lambda d: d["rain"] < 20)      # a callable

Chained, this reads like Polars while staying a pandas DataFrame:

(
    df.filter(pd.col("rain") < 20)
      .select("city", "temp")
      .sort_values("temp")
)

Missing values in the mask count as False by default; pass na=True to keep them or na="raise" to error out.

The trap: a plain list of booleans passed positionally still goes down the label path. In our test df.filter([True, False, True]) returned a DataFrame with zero columns and a UserWarning telling us to use cond=. Always write cond= when the mask is a list or array.

Polars vs pandas 3.1 side by side

TaskPolarspandas 3.1
Pick columnsdf.select("a", "b")df.select("a", "b")
Compute a columndf.select(pl.col("a") * 2)df.select(pd.col("a") * 2)
Name it(pl.col("a") * 2).alias("a2")df.select(a2=pd.col("a") * 2)
Filter rowsdf.filter(pl.col("a") > 1)df.filter(pd.col("a") > 1)
Aggregatedf.select(pl.col("a").mean()) → 1 rowdf.select(m=pd.col("a").mean()) → broadcast to every row

pandas is not becoming Polars: there is no lazy evaluation and no query optimizer behind select. It is syntax that makes chains readable and lets you avoid repeating the DataFrame name. If you weigh switching engines, Polars vs pandas covers that decision.

Step 4: Get the faster read_csv, and know what turns it off

The benchmark (tested)

One file: 3,000,000 rows × 3 columns (float, int, string), 72.5 MB, uncompressed, on local SSD. Best of three runs.

ReaderTimevs pandas 3.0.1
pandas 3.0.1, engine="c"0.233 s1x
pandas 3.1.0, mode.max_threads = 1 (serial)0.089 s2.6x
pandas 3.1.0, 2 threads0.049 s4.8x
pandas 3.1.0, 4 threads0.025 s9.3x
pandas 3.1.0, default (6 threads)0.018 s13x
pandas 3.1.0, engine="pyarrow"0.030 s7.8x

The serial row matches the release notes ("two to three times faster"); the parallel rows come on top of that. Your file will differ (more string columns parse slower), but the shape of the result is the point: the default engine="c" reader is now faster than PyArrow for a plain local file.

The conditions (docs, confirmed by the timings above)

The parallel path runs automatically when all of these hold:

  • filepath_or_buffer is a local, uncompressed file path
  • the default C engine is used
  • the data rows total at least 5 MB
  • mode.max_threads is more than 1 (default: CPU count, capped at 6, and 1 when the process is limited to one CPU)
  • no option that needs the whole file at once: iterator, chunksize, nrows, usecols, index_col, parse_dates, a list or callable skiprows, multi-row headers, or a non-UTF-8 encoding

Anything else falls back to the serial reader, and the result is always identical to a serial read.

What that looked like in our test (tested)

CallTimeParallel?
pd.read_csv("big.csv")0.018 syes
pd.read_csv("big.csv", usecols=["a", "c"])0.079 sno
pd.read_csv("big.csv", index_col=0)0.091 sno
pd.read_csv("big.csv", nrows=1_000_000)0.032 sno (serial, one third of the file)
pd.read_csv("big.csv", converters={"b": int})0.255 sno (slower than a plain read, still faster than the same call on 3.0)
pd.read_csv(open("big.csv", "rb"))0.091 sno (file handle, not a path)
pd.read_csv(io.StringIO(text))0.131 sno
pd.read_csv("big.csv.gz")0.163 sno (compressed)
pd.concat(pd.read_csv("big.csv", chunksize=500_000))0.099 sno
pd.read_csv("big.csv", dtype={...})0.034 syes (explicit dtype is fine)
pd.read_csv("big.csv", na_values=["z"])0.025 syes

Two practical consequences:

  • usecols can now make a read slower, not faster. For years the advice was "pass usecols to skip columns you do not need." On a local file ≥ 5 MB in 3.1, reading everything in parallel and dropping columns afterwards was 4x faster than usecols in our test. Measure before you keep the old habit.
  • Pass a path, not a handle. pd.read_csv("file.csv") is parallel; pd.read_csv(open("file.csv")) is not. Wrappers that hand pandas an open file object instead of a path lose the speedup.

Turn it down inside an already-parallel application

If pandas runs inside Dask, Ray, a multiprocessing pool or a web worker that already uses all cores, cap the threads so they do not fight:

import pandas as pd
 
with pd.option_context("mode.max_threads", 1):
    df = pd.read_csv("large.csv")
 
# or globally
pd.set_option("mode.max_threads", 2)

In Docker and Kubernetes the cap follows the cgroup CPU quota (docs, not tested here), so a container limited to one CPU reads serially without you doing anything.

Step 5: Check the behavior changes that do not raise

These are the changes most likely to alter a result quietly. Each was reproduced on 3.0.1 and 3.1.0 (tested).

Timedelta and datetime from numbers now default to microseconds

In 3.0, pd.to_timedelta([1], unit="s") produced timedelta64[s], and arithmetic kept that resolution. Dividing it rounded to whole seconds:

td = pd.to_timedelta([1], unit="s")
print(td / 3)
# pandas 3.0.1 → TimedeltaIndex(['0 days'], dtype='timedelta64[s]', freq=None)
# pandas 3.1.x → TimedeltaIndex(['0 days 00:00:00.333333'], dtype='timedelta64[us]', freq=None)

3.1 fixes this by defaulting to microseconds regardless of the unit you convert with. If your code compared dtypes or relied on [s] resolution, use .as_unit("s") explicitly. Timestamp and to_datetime with numeric input changed the same way.

Related: Timedelta.total_seconds() now includes nanoseconds. pd.Timedelta(nanoseconds=1500).total_seconds() returns 1.5e-06 instead of 1e-06.

dtype="category" in read_csv infers numeric categories

from io import StringIO
data = "zip\n00501\n00501\n10001"
 
pd.read_csv(StringIO(data), dtype="category")["zip"].cat.categories.tolist()
# pandas 3.0.1 → ['00501', '10001']
# pandas 3.1.0 → [501, 10001]        leading zeros gone

The C engine now does what the PyArrow engine already did. For zip codes, account numbers and any zero-padded identifier, read the column as str and convert afterwards:

df = pd.read_csv("file.csv", dtype={"zip": "str"})
df["zip"] = df["zip"].astype("category")

Things that used to pass silently and now raise

  • pd.Timestamp(datetime(2020, 12, 31), hour=5) raised ValueError in our test; in 3.0 it silently ignored hour. Pass all components by keyword instead.
  • pd.to_timedelta("3:25:00 PM") raises; 3.0 parsed it as three hours and dropped the PM.
  • DataFrame arithmetic and ordering comparisons with a category Series along the default axis="columns" (for example df + cat_series) now raise TypeError instead of operating on the categories; equality is unchanged (docs, reproduced).
  • Index.values and Index.array return read-only arrays, so code that mutated an index through them now fails loudly instead of corrupting lookups (docs).

Regex on PyArrow-backed strings (tested)

With PyArrow installed, \w, \d, \s, \b in .str.replace, .str.count and friends matched ASCII only in pandas 3.0. In 3.1 they match Unicode again, consistent with Python's re and pandas 2.x. With PyArrow 26.0.1, pd.Series(["héllo wörld", "naïve 東京"]).str.count(r"\w") returned [8, 4] on pandas 3.0.6 and [10, 7] on 3.1.1. If you process non-English text and saw counts drop after moving to 3.0, this is the fix. 3.1.1 also fixes \s not matching \x0b and \x1c on PyArrow strings.

Should you upgrade now?

SituationRecommendation
New project, Python 3.11+Yes. Start on 3.1 and write without inplace
Scripts and notebooks on 3.0Yes. Run once with -W "error:The inplace keyword:DeprecationWarning" (or pytest -W error::pandas.errors.Pandas4Warning), fix the lines, and keep the faster CSV reads
Library or pipeline that loads zero-padded IDs with dtype="category"Upgrade, but add dtype={"id": "str"} first
Depends on FLAML, darts or similarUpgrade when they release their 3.1 fixes (yfinance already did, in 1.8.0), or filter Pandas4Warning meanwhile
Still on pandas 2.xDo the 2 → 3 migration first (Copy-on-Write, string dtype); 3.1 is small on top of that
Still on NumPy 1.xBlocked. Upgrade NumPy first

Using pandas 3.1 in Jupyter

Nothing changes in the notebook itself. Two notes:

  • -W error is a command-line flag and does not apply to a running kernel. Put warnings.simplefilter("error", pd.errors.Pandas4Warning) in the first cell while you clean up, then remove it.
  • The parallel CSV reader does not need the free-threaded Python build; it works on the standard interpreter.

Once a large file loads in a fraction of a second, the slow part is looking at it. PyGWalker (opens in a new tab) 0.5.0.1 worked on pandas 3.1 in our test (pyg.to_html(df) rendered a 50,000-row frame in 0.23 s), so a drag-and-drop pass over the freshly loaded DataFrame is a reasonable next step before you write the groupby code.

Troubleshooting

ResolutionImpossible / "requires numpy>=2.3.3" — you are on Python 3.14 or 3.15 with an old NumPy pin. Upgrade NumPy or loosen the pin. On Python 3.11–3.13 the floor is 2.0.2.

Hundreds of Pandas4Warning lines from site-packages — a dependency, not your code. Update it; if no fix exists yet, warnings.filterwarnings("ignore", category=pd.errors.Pandas4Warning) and open an issue with the traceback.

read_csv is not faster — check the list in Step 4. The usual culprits are usecols, index_col, a compressed file, or a file object instead of a path. Also confirm pd.get_option("mode.max_threads") is not 1 (it is None by default, which means "auto").

df.filter([...]) returned no columns — you passed a boolean list positionally. Use df.filter(cond=[...]).

AttributeError: 'Series' object has no attribute 'alias' — pd.col expressions have no working alias; name the column with the keyword form of select.

Zip codes lost their leading zeros — dtype="category" now infers integers. Read as str first.

Timedelta dtype changed from [s] to [us] — expected in 3.1. Use .as_unit("s") if a downstream system needs seconds.

FAQ

What is Pandas4Warning and how do I fix it?

It is the deprecation warning pandas 3.1 raises for behavior that changes in 4.0, most often inplace=True on drop, rename, reset_index, set_index, sort_values, dropna and drop_duplicates. Reassign the result instead: df = df.drop(columns=["a"]). Run python -W "error:The inplace keyword:DeprecationWarning" your_script.py (or pytest -W error::pandas.errors.Pandas4Warning for a test suite) to stop with a traceback at the first remaining call; fix it and rerun.

Is inplace=True removed in pandas 3.1?

No. It still works in 3.1 and prints a warning. Removal is scheduled for pandas 4.0, and only for the methods that never modified data in place. fillna, replace, where, mask, clip and interpolate keep the keyword.

Why is read_csv faster, and why is mine not?

The C engine is 2–3x faster and large local files are read in parallel by default. If you see no change, you are probably passing usecols, index_col, nrows, chunksize, a compressed file, or an open file object. See the table in Step 4.

Is df.select() the same as Polars select?

The syntax is deliberately similar, but pandas evaluates eagerly and has no query optimizer. Use it for readable chains, not for performance.

Does pandas 3.1 work with Python 3.15?

Yes. Wheels are published for CPython 3.11 through 3.15. On 3.14 and 3.15 you need NumPy 2.3.3 or newer. For the Python side of that upgrade see our Python 3.15 upgrade guide.

Related Guides