pandas patterns that actually scale
Method chaining, categoricals, groupby-agg, and the SettingWithCopy warning explained.
Why this matters
Pandas is the standard for tabular data in Python โ ETL, analytics, ML feature engineering. The API is large; this lesson covers the 20% you'll use 80% of the time, plus the patterns that separate "I read the docs" from "I move fast in real datasets."
Learning objectives
- Create, index, and filter DataFrames.
- Use
groupby,merge,pivot,melt. - Handle missing data, types, dates.
- Avoid the most common slow / wrong patterns.
- Know when to switch to Polars or Spark.
1. Series and DataFrame
import pandas as pd
import numpy as np
# Series โ 1D, labelled
s = pd.Series([1, 2, 3], index=["a", "b", "c"], name="vals")
# DataFrame โ 2D, labelled rows + columns
df = pd.DataFrame({
"name": ["Ada", "Bob", "Cara"],
"age": [30, 25, 35],
"city": ["NY", "SF", "LA"],
})
df.head(); df.tail(); df.info(); df.describe(); df.dtypes
df.shape; df.columns; df.indexThe index is meaningful โ it's how rows are addressed. Set it deliberately:
df = df.set_index("name")
df.loc["Ada"] # row by label
df.reset_index() # turn index back into a column2. Reading / writing
df = pd.read_csv("data.csv", parse_dates=["timestamp"], dtype={"id": "int32"})
df = pd.read_parquet("data.parquet")
df = pd.read_excel("file.xlsx", sheet_name="Sheet1")
df = pd.read_json("data.jsonl", lines=True)
df = pd.read_sql("SELECT * FROM t", conn)
df.to_csv("out.csv", index=False)
df.to_parquet("out.parquet") # much faster + smaller than CSV
df.to_json("out.jsonl", orient="records", lines=True)Parquet is the right format for storage โ 10ร smaller, 10ร faster, types preserved. CSV only for human readability / interchange with non-Python tools.
3. Selecting and filtering
df["age"] # column โ Series
df[["name", "age"]] # columns โ DataFrame
df.age # attribute (only when col is a valid identifier)
# By position
df.iloc[0] # first row (Series)
df.iloc[0:3] # first 3 rows
df.iloc[0:3, [0, 2]] # 2-D position-based
# By label
df.loc["Ada"]
df.loc["Ada":"Cara", ["age", "city"]]
# Boolean mask
df[df.age > 28]
df[(df.age > 28) & (df.city == "NY")] # parens!
df.query("age > 28 and city == 'NY'") # string query (sometimes clearer)
# `.isin`
df[df.city.isin(["NY", "LA"])]loc vs iloc:
loc[label, label]โ label-based.iloc[position, position]โ integer-based.df[col]โ column selector (label).df[mask]โ boolean row filter.
Avoid chained assignment (df[df.x > 0]["y"] = 5) โ it triggers SettingWithCopyWarning and may or may not work. Use df.loc[df.x > 0, "y"] = 5.
4. Modifying columns
df["age_squared"] = df["age"] ** 2
df["full_name"] = df["first"] + " " + df["last"]
df = df.assign(
high=lambda d: d.age > 30,
decade=lambda d: (d.age // 10) * 10,
)
df.drop(columns=["age_squared"], inplace=False) # always prefer non-inplace
df.rename(columns={"age": "years"})
df.astype({"age": "int32"}).assign(...) is the chainable way to add columns โ keeps your code as a pipeline.
5. Missing data
df.isna().sum() # count NaNs per column
df.dropna() # drop rows with any NaN
df.dropna(subset=["age"])
df.fillna(0)
df.fillna({"age": df.age.median(), "city": "unknown"})
df.ffill() # forward-fill
df.bfill() # back-fill
df.interpolate() # linear by defaultFor booleans / ints with NA, use the nullable dtypes:
df["age"] = df["age"].astype("Int64") # capital I โ allows pd.NA
df["ok"] = df["ok"].astype("boolean")6. groupby โ split / apply / combine
df.groupby("city").size()
df.groupby("city")["age"].mean()
df.groupby("city").agg(
avg_age=("age", "mean"),
max_age=("age", "max"),
n=("age", "count"),
)
# Multiple keys
df.groupby(["city", "department"]).agg(...)
# Apply a custom function
df.groupby("city")["age"].apply(lambda s: s.quantile(0.95))
# Transform โ same shape as input (broadcast group stat back to each row)
df["age_z"] = df.groupby("city")["age"].transform(lambda s: (s - s.mean()) / s.std())
# Filter โ keep entire groups passing a predicate
df.groupby("city").filter(lambda g: len(g) > 10)agg for collapsed aggregations. transform when you need a column the same length as the input. filter when you want to keep/drop groups.
For speed, prefer built-in names ("mean", "sum") over apply(...) โ they hit C code.
7. Merging / joining
pd.merge(left, right, on="user_id", how="inner") # inner join
pd.merge(left, right, left_on="uid", right_on="user_id", how="left")
pd.merge(left, right, how="outer")
pd.merge(left, right, how="cross") # Cartesian
# By index
left.join(right, how="left")
# Concat (stacking)
pd.concat([df1, df2], axis=0) # rows
pd.concat([df1, df2], axis=1) # cols (align on index)how: "inner", "left", "right", "outer", "cross".
Set validate="one_to_many" or "one_to_one" to catch duplicate-key bugs.
8. Reshaping: pivot, melt, stack, unstack
# Pivot (long โ wide)
df.pivot(index="date", columns="city", values="temp")
# Pivot with aggregation
df.pivot_table(index="date", columns="city", values="temp", aggfunc="mean")
# Melt (wide โ long)
df.melt(id_vars=["date"], value_vars=["NY", "SF", "LA"], var_name="city", value_name="temp")
# Stack / unstack (hierarchical index)
df.set_index(["date", "city"]).unstack("city")Most "I want to reshape this table" tasks are pivot_table or melt. Reach for them before reaching for loops.
9. Date and time
df["ts"] = pd.to_datetime(df["ts"])
df["ts"].dt.year, df["ts"].dt.dayofweek, df["ts"].dt.month_name()
# Index by datetime
df = df.set_index("ts").sort_index()
df["2026-01":"2026-03"] # slice by string
df.resample("D").mean() # daily mean
df.resample("W").sum() # weekly sum
df.rolling("7D").mean() # 7-day rolling
df.shift(1) # lag
df.tz_localize("UTC").tz_convert("America/New_York")For lossy aggregations, always specify the resample rule explicitly.
10. String ops
df["name"].str.upper()
df["email"].str.split("@", n=1, expand=True)
df["text"].str.contains(r"\d+", regex=True)
df["text"].str.replace(r"\s+", " ", regex=True)
df["text"].str.len()
df["text"].str.extract(r"(\d+)-(\d+)")All string accessors live under .str. Vectorised; faster than apply.
11. Apply, map, vectorisation
# Bad
df["squared"] = df["x"].apply(lambda x: x*x) # Python loop
# Good
df["squared"] = df["x"] ** 2 # vectorised
# Bad
df["clean"] = df["text"].apply(lambda s: s.strip().lower())
# Good
df["clean"] = df["text"].str.strip().str.lower()
# Bad
for i, row in df.iterrows():
df.loc[i, "y"] = compute(row.a, row.b) # row-by-row mutation
# Good
df["y"] = compute_vectorised(df["a"], df["b"]) # vector inputsapply and iterrows are the two biggest sources of slow pandas. Avoid both.
If you genuinely need row-wise logic that doesn't vectorise, use numpy broadcasting or Polars (Lesson 5.3) which expresses these operations beautifully.
12. Categorical and efficient dtypes
df["city"] = df["city"].astype("category") # huge memory + speed win for low-cardinality strings
df["age"] = df["age"].astype("int32") # int32 instead of int64 cuts memory in half
df["amount"] = df["amount"].astype("float32") # if precision allowsFor large DataFrames, type tuning saves GB of RAM.
df.memory_usage(deep=True) shows actual memory per column.
13. Method chaining (pandas pipelines)
result = (
pd.read_csv("orders.csv", parse_dates=["placed_at"])
.query("status == 'paid' and amount > 0")
.assign(day=lambda d: d.placed_at.dt.date)
.groupby("day")["amount"].sum()
.rename("daily_revenue")
.reset_index()
)The chained style reads top-to-bottom like SQL. Use df.pipe(my_func) to plug in your own functions:
def add_features(d): return d.assign(decade=lambda x: (x.age // 10) * 10)
result = df.pipe(add_features).groupby("decade").mean()14. When pandas isn't the right tool
| Dataset size | Tool |
|---|---|
| < 1 GB in memory | pandas |
| 1-50 GB single machine | polars (faster, lazy, multicore) or DuckDB |
| > 50 GB | DuckDB, Dask, Ray, Spark |
| Streaming | Pandas + chunks, Polars streaming, Kafka + Flink |
Polars (Lesson 5.3) is often 5-30ร faster than pandas with a similar API. If you're starting a new analytics project in 2026, strongly consider Polars first.
15. Worked example: e-commerce analytics
import pandas as pd
orders = pd.read_csv("orders.csv", parse_dates=["placed_at"])
users = pd.read_csv("users.csv")
# Merge
df = orders.merge(users, on="user_id", how="left", validate="many_to_one")
# Feature engineering
df = df.assign(
day=lambda d: d.placed_at.dt.date,
is_weekend=lambda d: d.placed_at.dt.dayofweek >= 5,
aov=lambda d: d.revenue / d.units,
)
# Aggregate
daily = (
df.query("status == 'paid'")
.groupby("day")
.agg(
revenue=("revenue", "sum"),
orders=("order_id", "count"),
aov=("aov", "mean"),
)
.sort_index()
)
# 7-day moving averages
daily["revenue_7d"] = daily.revenue.rolling(7).mean()
daily["orders_7d"] = daily.orders.rolling(7).mean()
# Output
daily.to_parquet("daily_revenue.parquet")Self-contained ETL in 20 lines, no loops.
Hands-on lab (3 hours)
- Load a CSV; print
info(),describe(),isna().sum(). - Filter rows with multiple conditions; both
[]mask and.query(). - Add features with
.assign(...)(lag of a column, weekday flag). - Group by two columns; aggregate three metrics; reset index.
- Pivot from long to wide; melt back; confirm equality.
- Compute 28-day rolling mean of a daily series.
- Compare
apply(lambda)vs vectorised on 1M rows. Time both. - Convert a string column to
category; observe memory drop. - Bonus: rewrite the same pipeline in Polars (next lesson preview).
Common pitfalls
SettingWithCopyWarningโ solved bydf.loc[mask, "col"] = ....applyover rows (iterrows,apply(axis=1)) when vectorisation works.- Implicit type changes (int โ float when NaN appears). Use nullable Int64.
- Forgetting
parse_dates=on read; date columns silently stay strings. - Missing
validate=on merges โ silent duplication. - Using
inplace=True(deprecated, will be removed). Usedf = df.foo(...). - Reading 5 GB CSV โ swap. Chunk with
chunksize=or switch to Polars/DuckDB.
Self-check
- Difference between
locandiloc. - When use
applyvs vectorised ops? mergevsconcat?transformvsagg?- Why is Parquet preferred over CSV for storage?
References
- Python for Data Analysis, 3rd ed., Wes McKinney.
- Pandas user guide: https://pandas.pydata.org/docs/user_guide/.
- "Modern Pandas" series by Tom Augspurger.
- Pandas + Arrow integration: https://pandas.pydata.org/pandas-docs/stable/user_guide/pyarrow.html.
Sign in to save your progress and earn badges.