Performance & Memory Tips
Make pandas faster and lighter: measure memory, use the category dtype, downcast numbers, load only needed columns with usecols/dtype, stream big files with chunksize, vectorise, and chain methods.
The IPL file in notebook 06 has 816 rows — tiny. But ball-by-ball IPL data has over 200,000 rows, and real company files have millions. At that size two things hurt: memory (the laptop runs out of RAM) and speed (a cell runs for minutes). This topic collects the habits that fix both.
Definition
Pandas performance tuning means reducing the memory a DataFrame occupies (by choosing smaller or smarter data types and loading only what you need) and reducing run time (by using vectorised, compiled operations instead of Python loops).
- Memory depends on rows × columns × bytes per value — and the dtype decides the bytes per value. Text is the most expensive.
- Run time depends mostly on whether the work happens in compiled code (vectorised) or in a Python loop.
Syntax — the toolkit
python
df.memory_usage(deep=True) # bytes per column (deep=True counts text properly)
df.info(memory_usage="deep")
s.astype("category") # repeated text → small integer codes
pd.to_numeric(s, downcast="integer") # int64 → int8/int16/int32 if values fit
s.astype("float32") # half the memory of float64
pd.read_csv(path, usecols=[...], dtype={...}) # load only what you need, with small types
for chunk in pd.read_csv(path, chunksize=100_000): ... # stream a big file| Function / parameter | Meaning | Default |
|---|---|---|
memory_usage(deep=) | True measures the real size of text values | False |
astype("category") | store each distinct value once + an integer code per row | — |
to_numeric(downcast=) | "integer", "signed", "unsigned" or "float" — smallest type that fits | None |
read_csv(usecols=) | list of columns to load; others are skipped | all columns |
read_csv(dtype=) | dict of column → dtype, applied while reading | inferred |
read_csv(chunksize=) | rows per chunk; returns an iterator (TextFileReader) instead of a DataFrame | None |
Example — measure, then shrink with category and downcasting
python
import pandas as pd
import numpy as np
rng = np.random.default_rng(42)
n = 100_000
teams = np.array(["MI", "CSK", "RCB", "KKR", "DC", "SRH", "RR", "PBKS", "GT", "LSG"])
df = pd.DataFrame({
"team": rng.choice(teams, n),
"runs": rng.integers(0, 150, n),
"balls": rng.integers(1, 100, n),
"strike_rate": rng.uniform(50, 250, n),
})
print(df.memory_usage(deep=True))
print(f"total: {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")text
Index 132
team 5170222
runs 800000
balls 800000
strike_rate 800000
dtype: int64
total: 7.22 MBThe text column team alone uses 5.2 MB — more than the three numeric columns together (8 bytes × 100,000 = 800,000 bytes each).
python
df["team"] = df["team"].astype("category")
for col in ["runs", "balls"]:
df[col] = pd.to_numeric(df[col], downcast="integer")
df["strike_rate"] = df["strike_rate"].astype("float32")
print(df.dtypes)
print(f"total after: {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")text
team category
runs int16
balls int8
strike_rate float32
dtype: object
total after: 0.76 MBHow it works:
- category — only 10 distinct teams, so pandas stores the 10 names once and keeps a tiny integer code (1 byte) per row: 5,049 KB → 98 KB.
- downcast —
balls(1–99) fits inint8(−128…127) andruns(0–149) needsint16: 8 bytes → 1–2 bytes per value. - float32 — 4 bytes instead of 8; plenty of precision for a strike rate.
Total: 7.22 MB → 0.76 MB, almost 10× smaller. (Exact byte counts can vary slightly between machines and pandas builds.)
| dtype | Bytes per value | Range / note |
|---|---|---|
int8 | 1 | −128 to 127 |
int16 | 2 | −32,768 to 32,767 |
int32 | 4 | about ±2.1 billion |
int64 | 8 | default for integers |
float32 | 4 | ~7 significant digits |
float64 | 8 | default for decimals |
category | codes (1–2) + each unique value once | great when few distinct values repeat |
str | size of each text value | default for text in pandas 3 |
Tip:
categoryhelps when a column has few unique values compared with its length (teams, cities, fuel type). For a column of unique player names it can make memory worse. Checks.nunique() / len(s)first.
Example — load only what you need: usecols + dtype
python
small = pd.read_csv(path, usecols=["season", "team", "runs"],
dtype={"team": "category", "season": "int16", "runs": "int16"})
print(small.dtypes)text
season int16
team category
runs int16
dtype: objectOn a 1,000-row, 7-column test file (simulated with io.StringIO), loading everything took 244,092 bytes, while usecols + dtype took 5,949 bytes. Columns you never use (umpires, venue IDs…) never enter memory, and the types are small from the start.
Example — stream a big file with chunksize
python
totals = None
for chunk in pd.read_csv(path, usecols=["team", "runs"], chunksize=250):
part = chunk.groupby("team")["runs"].sum()
totals = part if totals is None else totals.add(part, fill_value=0)
print(totals.astype(int).sort_values(ascending=False).head(3))text
team
PBKS 10400
DC 10300
CSK 10200
Name: runs, dtype: int64How it works: with chunksize, read_csv returns a TextFileReader iterator; each loop gives a DataFrame of up to 250 rows. You compute a partial result per chunk and combine them with .add(..., fill_value=0). Only one chunk is in memory at a time, so you can process files bigger than your RAM. The answer matches a full groupby on the whole file.
Speed habits
| Habit | Why |
|---|---|
Vectorise — column maths, np.where, np.select, .str, .dt | compiled loop instead of Python loop (often 100×+ faster) |
Avoid row loops — iterrows, apply(axis=1) | one Python call + object per row |
Use built-in aggregations — groupby().sum(), "mean" | optimised C code; custom lambdas in agg are slower |
| Filter early, select columns early | later steps work on less data |
| Category for group keys | groupby on a category column is faster |
| Don't grow DataFrames in a loop | each concat/append copies everything; build a list, then one DataFrame |
| Method chaining | no half-finished intermediate copies lying around; with Copy-on-Write, steps share memory until data changes |
inplace=True is not faster | it usually still copies internally; in pandas 3 it is discouraged — reassign instead |
Common mistakes
- ❌
df.memory_usage()withoutdeep=True— text columns look tiny. ✅ Alwaysdeep=Truefor real numbers. - ❌ Converting high-cardinality columns (unique IDs, names) to category. ✅ Only columns with few repeated values.
- ❌ Downcasting a column and later adding values that overflow (int8 max is 127). ✅ Leave headroom, or downcast only final analysis tables.
- ❌ Loading a 2 GB CSV fully to use 3 columns. ✅
usecols+dtype, orchunksize. - ❌ Optimising before measuring. ✅ Measure memory and time first, fix the biggest problem.
Interview tip
Q: A 5 GB CSV doesn't fit in memory. What do you do?
A: Load only the needed columns (usecols) with compact types (dtype with category/int16/float32). If it still doesn't fit, process it in pieces with chunksize, computing partial aggregates per chunk and combining them. Also consider a columnar format like Parquet for repeated use.
Key points to remember
- Measure first: df.memory_usage(deep=True) — deep=True is needed to count text correctly.
- Text columns are the most expensive; category stores each distinct value once plus small integer codes.
- Use category only for columns with few distinct, repeated values (teams, cities, fuel type).
- Downcast numbers: pd.to_numeric(s, downcast='integer'), astype('float32') — 8 bytes → 1–4 bytes.
- read_csv(usecols=..., dtype=...) loads only needed columns with small types from the start.
- read_csv(chunksize=n) returns an iterator of DataFrames; combine partial results to process files bigger than RAM.
- For speed: vectorise, avoid row loops, use built-in aggregations, filter early, and chain methods.
Try it yourself
Shrink a DataFrame: category + downcast
usecols, dtype and chunksize
Practice: when does category help?
Revision Flashcards
Tap a card to flip it and see the answer.
