pandas 3.1 came out on 22 October 2026, and the docs are already on the 3.1.1 patch. It’s a minor version number, but there are real day-to-day improvements: a new DataFrame.select() method, boolean-mask filtering in filter(), and a much faster read_csv() that can read big files in parallel. This tutorial walks through each one with runnable code, then covers the deprecations you should fix before upgrading.

Install or Upgrade

pandas 3.1 requires NumPy 2.0.2 or newer and no longer supports NumPy 1.x. Upgrade both together:

pip install --upgrade "pandas>=3.1" "numpy>=2.0.2"
python -c "import pandas as pd, numpy as np; print(pd.__version__, np.__version__)"

1. DataFrame.select(): Pick and Compute Columns in One Call

DataFrame.select() returns a new DataFrame with only the columns you ask for. You can also compute new columns in the same call, using pd.col() expressions (added in pandas 3.0) or callables passed as keyword arguments. As with assign(), a later argument can refer to a column created earlier in the same call.

import pandas as pd

df = pd.DataFrame(
    {
        "first_name": ["John", "Alice", "Bob"],
        "last_name": ["Smith", "Cooper", "Marley"],
        "age": [61, 22, 35],
    }
)

# Plain column selection
print(df.select("first_name", "last_name"))

# Keep a column and compute a new one
print(df.select("first_name", pd.col("age"), age_months=pd.col("age") * 12))

# Later expressions can use columns created earlier in the same call
print(
    df.select(
        "first_name",
        age_months=pd.col("age") * 12,
        age_days=pd.col("age_months") * 30,
    )
)

The last call prints age_months values of 732, 264 and 420, and age_days values of 21960, 7920 and 12600. That matches the official release notes.

Why it matters

Before this, you’d usually write df[["a", "b"]].assign(c=...), which is two steps and clumsy in long method chains. select() does both in one step and reads much like the Polars method of the same name. That’s handy if you move between the two libraries.

2. filter() Now Accepts a Boolean Mask

DataFrame.filter() and Series.filter() used to select only by labels. In 3.1 they take a new cond keyword that filters rows using a boolean mask. You can pass a mask, a callable that returns a mask, or a pd.col() expression. The callable and expression forms can also be passed positionally, which works nicely in chains.

import pandas as pd

df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})

print(df.filter(cond=df["a"] > 1))        # boolean Series
print(df.filter(pd.col("a") > 1))         # expression, positional
print(df.filter(lambda d: d["b"] < 6))    # callable, positional

Missing values in the mask

By default, missing values in the mask count as False, the same as df[mask] with a nullable boolean mask. Pass na=True to keep those rows, or na="raise" to get an error instead:

mask = pd.array([True, None, False], dtype="boolean")

print(df.filter(cond=mask))           # keeps row 0 only
print(df.filter(cond=mask, na=True))  # keeps rows 0 and 1

A small gotcha

Any other value passed positionally still selects labels. A positional list of booleans now raises a UserWarning, because you probably meant a mask. Use items= for labels or cond= for masks. Selecting labels with items, like or regex will be deprecated in a future version, so new code should use select() for columns and filter(cond=...) for rows.

3. A Readable Method Chain

Here’s how the new methods fit together in a realistic cleanup chain:

import pandas as pd

orders = pd.DataFrame(
    {
        "order_id": [101, 102, 103, 104],
        "qty": [2, 0, 5, 1],
        "unit_price": [499.0, 250.0, 120.0, 999.0],
        "country": ["IN", "IN", "US", "IN"],
    }
)

report = (
    orders
    .filter(pd.col("qty") > 0)
    .select(
        "order_id",
        "country",
        revenue=pd.col("qty") * pd.col("unit_price"),
        revenue_with_gst=pd.col("revenue") * 1.18,
    )
    .sort_values("revenue", ascending=False)
)

print(report)

4. Faster read_csv(), With Parallel Reads

The default C engine is a lot faster in 3.1. The pandas team reports that a single-threaded read is typically two to three times faster than in 3.0. On top of that, large files are now read in parallel across several CPU cores, and this is on by default. It kicks in when:

  • the input is a local, uncompressed file of at least 5 MB, and
  • you haven’t passed options that require parsing the whole file at once.

The result is identical to a serial read. The new mode.max_threads option controls the thread count. It defaults to the number of CPUs available to the process, capped at 6. Set it to 1 if your app already parallelises work, for example inside a multiprocessing pool or a busy web worker:

import pandas as pd

# Default: parallel for big local files
df = pd.read_csv("large.csv")

# Force a serial read in one block of code
with pd.option_context("mode.max_threads", 1):
    df_serial = pd.read_csv("large.csv")

Benchmark it yourself

import time
import numpy as np
import pandas as pd

rng = np.random.default_rng(0)
pd.DataFrame(rng.random((2_000_000, 5)), columns=list("abcde")).to_csv("bench.csv", index=False)

for threads in (1, 6):
    with pd.option_context("mode.max_threads", threads):
        t = time.perf_counter()
        pd.read_csv("bench.csv")
        print(f"threads={threads}: {time.perf_counter() - t:.2f}s")

Your numbers will depend on your CPU and disk. Measure on your own machine instead of trusting anyone’s headline figure.

5. Deprecations to Fix Now

inplace is going away for most methods

Under PDEP-8, the inplace keyword is deprecated for methods that don’t actually work in place: drop(), rename(), rename_axis(), reset_index(), set_index(), dropna(), drop_duplicates(), sort_values(), sort_index(), eval() and query(). Reassign the result instead. The release notes say it’s no less efficient:

# Before (now warns)
df.reset_index(inplace=True)

# After
df = df.reset_index()

fillna() and replace() keep the keyword, because they can update values in place.

Other changes worth a quick search in your codebase

  • read_csv(..., dtype="category") now infers numeric and boolean categories on every engine. Zero-padded IDs such as PIN codes can lose their leading zeros. Read them as str first if you need to keep them.
  • Numeric input to to_datetime(), to_timedelta(), Timestamp and Timedelta now defaults to microsecond resolution.
  • Lossy .values on timezone-aware, period and interval data is deprecated. Use .array or .to_numpy().
  • engine="fastparquet" is deprecated because the fastparquet library has been retired. Use pyarrow.

To see these warnings while you test, run your suite with warnings turned into errors: python -W error::FutureWarning -m pytest. You can also filter on pandas’ own warning classes.

Takeaway

pandas 3.1 makes everyday code shorter and faster. Use select() for columns, filter(cond=...) for rows, and let read_csv() use your cores. Before you upgrade production code, move to NumPy 2, remove deprecated inplace=True calls, and check any category or datetime parsing your pipelines rely on. Full details are in the What’s new in 3.1.0 page.