Performance: categoricals, vectorising & method chaining
pandas is fast when you let it work on whole columns and slow when you make it loop in Python. A few habits can cut run time from minutes to seconds and memory from gigabytes to megabytes, while making code easier to read.
Measure first
df.info(memory_usage="deep")
df.memory_usage(deep=True).sort_values(ascending=False)
deep=True counts the real size of text columns, which are usually the biggest culprits.
Categoricals for repeated text
A column like state or payment_mode has a few distinct values repeated millions of times. The category dtype
stores each value once plus small integer codes:
df["state"] = df["state"].astype("category")
df["payment_mode"] = df["payment_mode"].astype("category")
On a 5-million-row table, this commonly shrinks those columns by 90% or more and makes groupby on them faster.
Use it when the number of unique values is small compared to the row count. It does not help for unique values
like invoice numbers.
Numbers can shrink too:
df["qty"] = pd.to_numeric(df["qty"], downcast="integer")
df["price"] = pd.to_numeric(df["price"], downcast="float")
Vectorise instead of apply
apply with axis=1 calls a Python function once per row, which is slow:
# Slow: one Python call per row
df["discount"] = df.apply(lambda r: r["amount"] * 0.1 if r["member"] else 0, axis=1)
# Fast: whole-column operations
import numpy as np
df["discount"] = np.where(df["member"], df["amount"] * 0.1, 0)
For several conditions, use np.select:
conditions = [df["amount"] >= 10_000, df["amount"] >= 5_000]
choices = ["Gold", "Silver"]
df["tier"] = np.select(conditions, choices, default="Regular")
| Task | Slow | Fast |
|---|---|---|
| If/else on a column | apply(axis=1) |
np.where, Series.where, mask |
| Many conditions | nested apply |
np.select, pd.cut |
| Lookups | apply with a dict |
Series.map(dict) or merge |
| Text cleaning | apply(str.strip) |
.str.strip() |
| Row loops | iterrows() |
column arithmetic |
.str methods are not as fast as numeric operations, but they are cleaner and still quicker than hand-written
loops.
Method chaining
Instead of many intermediate variables, write a readable pipeline:
report = (
pd.read_csv("orders.csv", parse_dates=["order_date"], dayfirst=True)
.query("status != 'Cancelled'")
.assign(
city=lambda d: d["city"].str.strip().str.title().astype("category"),
month=lambda d: d["order_date"].dt.to_period("M"),
net=lambda d: d["amount"] - d["discount"],
)
.groupby(["city", "month"], observed=True)["net"]
.sum()
.reset_index()
.sort_values(["month", "net"], ascending=[True, False])
)
assign with lambdas refers to the DataFrame at that step of the chain. pipe(func) lets you insert your own
functions. Pass observed=True when grouping by categoricals so empty combinations are not created.
Avoid chained assignment
df[df["city"] == "Pune"]["amount"] = 0 may modify a temporary copy and leave df unchanged. Use one loc call:
df.loc[df["city"] == "Pune", "amount"] = 0. With Copy-on-Write (the default behaviour from pandas 3.0, and
available in 2.x via pd.options.mode.copy_on_write = True), chained assignment never modifies the original.
Key takeaways
- Profile memory with
deep=True, then convert low-cardinality text tocategory. - Replace
apply(axis=1)anditerrowswith vectorised expressions. - Chains with
query,assignandpipemake logic easy to read and review. - Always assign with a single
.loc[rows, cols].
Practice
Build a DataFrame of 2,000,000 random orders with city, payment mode and amount. Record its deep memory usage,
convert suitable columns to category and downcast numbers, and compare. Then time an apply(axis=1) tier rule
against the np.select version with %timeit.