Pandas
Purpose
Transform and analyze tabular data correctly and at speed. Pandas makes it easy to write code that is slow, and easier still to write code that is silently wrong.
When to Use
- Cleaning, transforming, or analyzing tabular data in Python.
- A pandas operation that is slow or exhausting memory.
- Reviewing analysis code for correctness.
- Deciding whether the dataset has outgrown pandas.
Capabilities
- Vectorized operations and eliminating row-wise loops.
- Memory reduction through dtype selection.
- Merge and join semantics, including the ones that silently duplicate rows.
- Groupby, aggregation, and window functions.
- Chunked processing and the migration path to Polars or DuckDB.
Inputs
- The data source, its size, and its schema.
- The transformation or analysis required.
- The memory available.
Outputs
- Vectorized transformations with no
iterrows.
- Explicit dtypes, including categoricals for low-cardinality strings.
- Joins with verified cardinality.
Workflow
- Set dtypes at read time — Reading a CSV without
dtype gives you object columns and float64 for everything numeric. This is usually a 5-10x memory difference.
- Vectorize — Any
for loop or iterrows over a DataFrame should be a vectorized expression, a groupby, or a merge. apply is a loop with better syntax.
- Verify every join —
merge(..., validate="one_to_many"). An unvalidated join that is secretly many-to-many silently multiplies your rows, and the resulting totals will be wrong in a way that is hard to notice.
- Aggregate with groupby, not with loops — And use named aggregation so the output columns are readable.
- Chunk or switch when it does not fit — Pandas holds everything in memory, typically at several times the file size. Above a few gigabytes, use chunked processing, Polars, or DuckDB.
Best Practices
df.iterrows() is roughly a hundred times slower than the vectorized equivalent and should essentially never appear in production code.
- Chained assignment (
df[df.a > 1]["b"] = 0) may modify a copy and silently do nothing. Use .loc[]. In pandas 3.0 copy-on-write makes this an error rather than a silent no-op — which is an improvement.
- A
merge without validate= is a bet that the join keys are unique. When that bet is wrong, you get more rows than you started with and no warning.
category dtype for a string column with few distinct values can reduce memory by 90% and speeds up groupby substantially.
inplace=True does not save memory (it usually still copies) and prevents method chaining. It has no advantages.
- Read only the columns you need with
usecols. The cheapest optimization is not loading the data.
Examples
Reading efficiently, and joining safely:
import pandas as pd
orders = pd.read_csv(
"orders.csv",
usecols=["order_id", "customer_id", "status", "total_cents", "created_at"],
dtype={
"order_id": "string",
"customer_id": "string",
"status": "category", # 4 distinct values: 90% less memory than object
"total_cents": "int64",
},
parse_dates=["created_at"],
)
customers = pd.read_csv("customers.csv", usecols=["customer_id", "segment"],
dtype={"customer_id": "string", "segment": "category"})
# validate= turns a silent row explosion into a loud, immediate error.
enriched = orders.merge(
customers,
on="customer_id",
how="left",
validate="many_to_one", # many orders, one customer. Anything else raises.
)
Vectorized instead of looped — and correct:
# Slow (~100x) and easy to get wrong.
for idx, row in df.iterrows():
df.at[idx, "band"] = "high" if row["total_cents"] > 10_000 else "low"
# Vectorized, readable, and it does not mutate while iterating.
df["band"] = pd.cut(
df["total_cents"],
bins=[0, 10_000, 50_000, float("inf")],
labels=["low", "mid", "high"],
)
# Named aggregation: the output columns are named, not a MultiIndex to unpick.
summary = (
df.groupby(["segment", "band"], observed=True)
.agg(
order_count=("order_id", "count"),
revenue_cents=("total_cents", "sum"),
median_cents=("total_cents", "median"),
)
.reset_index()
)
Notes
observed=True on a groupby with categorical keys is important: without it, pandas produces a row for every possible category combination, including the ones with no data. On two categoricals with many levels this can generate an enormous, mostly empty frame.
- Polars is typically 5-30x faster than pandas on the same operations, uses less memory, and has a stricter API that catches errors pandas silently permits. For new analysis code on non-trivial data, it is the better default.
- DuckDB queries Parquet and CSV files directly with SQL, without loading them into memory. For "I need one aggregate from a 20 GB file", it is far simpler than any pandas approach.
1---2name: pandas3description: Use when analyzing or transforming tabular data in Python. Covers vectorized operations, memory-efficient dtypes, correct joins, groupby patterns, and avoiding the silent errors pandas makes easy.4---56# Pandas78## Purpose910Transform and analyze tabular data correctly and at speed. Pandas makes it easy to write code that is slow, and easier still to write code that is silently wrong.1112## When to Use1314- Cleaning, transforming, or analyzing tabular data in Python.15- A pandas operation that is slow or exhausting memory.16- Reviewing analysis code for correctness.17- Deciding whether the dataset has outgrown pandas.1819## Capabilities2021- Vectorized operations and eliminating row-wise loops.22- Memory reduction through dtype selection.23- Merge and join semantics, including the ones that silently duplicate rows.24- Groupby, aggregation, and window functions.25- Chunked processing and the migration path to Polars or DuckDB.2627## Inputs2829- The data source, its size, and its schema.30- The transformation or analysis required.31- The memory available.3233## Outputs3435- Vectorized transformations with no `iterrows`.36- Explicit dtypes, including categoricals for low-cardinality strings.37- Joins with verified cardinality.3839## Workflow40411. **Set dtypes at read time** — Reading a CSV without `dtype` gives you `object` columns and `float64` for everything numeric. This is usually a 5-10x memory difference.422. **Vectorize** — Any `for` loop or `iterrows` over a DataFrame should be a vectorized expression, a `groupby`, or a `merge`. `apply` is a loop with better syntax.433. **Verify every join** — `merge(..., validate="one_to_many")`. An unvalidated join that is secretly many-to-many silently multiplies your rows, and the resulting totals will be wrong in a way that is hard to notice.444. **Aggregate with groupby, not with loops** — And use named aggregation so the output columns are readable.455. **Chunk or switch when it does not fit** — Pandas holds everything in memory, typically at several times the file size. Above a few gigabytes, use chunked processing, Polars, or DuckDB.4647## Best Practices4849- `df.iterrows()` is roughly a hundred times slower than the vectorized equivalent and should essentially never appear in production code.50- Chained assignment (`df[df.a > 1]["b"] = 0`) may modify a copy and silently do nothing. Use `.loc[]`. In pandas 3.0 copy-on-write makes this an error rather than a silent no-op — which is an improvement.51- A `merge` without `validate=` is a bet that the join keys are unique. When that bet is wrong, you get more rows than you started with and no warning.52- `category` dtype for a string column with few distinct values can reduce memory by 90% and speeds up groupby substantially.53- `inplace=True` does not save memory (it usually still copies) and prevents method chaining. It has no advantages.54- Read only the columns you need with `usecols`. The cheapest optimization is not loading the data.5556## Examples5758**Reading efficiently, and joining safely:**5960```python61import pandas as pd6263orders = pd.read_csv(64 "orders.csv",65 usecols=["order_id", "customer_id", "status", "total_cents", "created_at"],66 dtype={67 "order_id": "string",68 "customer_id": "string",69 "status": "category", # 4 distinct values: 90% less memory than object70 "total_cents": "int64",71 },72 parse_dates=["created_at"],73)7475customers = pd.read_csv("customers.csv", usecols=["customer_id", "segment"],76 dtype={"customer_id": "string", "segment": "category"})7778# validate= turns a silent row explosion into a loud, immediate error.79enriched = orders.merge(80 customers,81 on="customer_id",82 how="left",83 validate="many_to_one", # many orders, one customer. Anything else raises.84)85```8687**Vectorized instead of looped — and correct:**8889```python90# Slow (~100x) and easy to get wrong.91for idx, row in df.iterrows():92 df.at[idx, "band"] = "high" if row["total_cents"] > 10_000 else "low"9394# Vectorized, readable, and it does not mutate while iterating.95df["band"] = pd.cut(96 df["total_cents"],97 bins=[0, 10_000, 50_000, float("inf")],98 labels=["low", "mid", "high"],99)100101# Named aggregation: the output columns are named, not a MultiIndex to unpick.102summary = (103 df.groupby(["segment", "band"], observed=True)104 .agg(105 order_count=("order_id", "count"),106 revenue_cents=("total_cents", "sum"),107 median_cents=("total_cents", "median"),108 )109 .reset_index()110)111```112113## Notes114115- `observed=True` on a groupby with categorical keys is important: without it, pandas produces a row for every *possible* category combination, including the ones with no data. On two categoricals with many levels this can generate an enormous, mostly empty frame.116- Polars is typically 5-30x faster than pandas on the same operations, uses less memory, and has a stricter API that catches errors pandas silently permits. For new analysis code on non-trivial data, it is the better default.117- DuckDB queries Parquet and CSV files directly with SQL, without loading them into memory. For "I need one aggregate from a 20 GB file", it is far simpler than any pandas approach.