Start hereQuick start · 6 you’ll reach for daily
Target versions · paceVersions
pandas ≥ 2.2
numpy ≥ 1.26
pyarrow ≥ 14
python ≥ 3.10
Pandas 2.x landed nullable dtypes via Arrow ("int64[pyarrow]",
"string[pyarrow]") — smaller, faster, with real NA semantics. df.append
is gone (use pd.concat). Copy-on-write (pd.set_option("mode.copy_on_write", True))
is opt-in in 2.2 and default in 3.0 — turn it on now to catch chained-assign warnings early.
This sheet pins to pandas 2.2+.
Install · engines · displaySetup
# Install — pandas + arrow backend (faster + nullable types)
pip install "pandas>=2.2" "pyarrow>=14"
# Conda — ships with sensible dependencies pinned
conda install -c conda-forge pandas pyarrow
# Engine extras worth knowing
pip install pandas[parquet,excel,plot,performance,html,sql]
# parquet → pyarrow / fastparquet
# excel → openpyxl / xlsxwriter
# performance → numba, bottleneck, numexpr
# plot → matplotlib
# Two display tweaks every notebook should set
python -c "
import pandas as pd
pd.options.display.max_columns = 50
pd.options.display.width = 160
pd.options.future.infer_string = True # 2.2+: opt into PyArrow strings
"
Where things liveCommon imports
| import pandas as pd | Canonical alias. |
| import numpy as np | Companion — df.values returns an ndarray. |
| from pandas import Timestamp, Timedelta, Period, DateOffset | Time-aware scalars + frequency math. |
| from pandas.api.types import is_numeric_dtype, is_datetime64_any_dtype | Type predicates for runtime checks. |
| from pandas.tseries.offsets import BDay, MonthEnd, Hour | Business-day / calendar offsets. |
| pd.set_option("mode.copy_on_write", True) | Recommended — default in 3.0. Catches chained-assign bugs. |
Two data containersSeries & DataFrame
| pd.Series([1,2,3], index=["a","b","c"], name="x") | 1-D labeled array. |
| pd.DataFrame({"a":[1,2], "b":[3,4]}) | 2-D table. Columns are Series. |
| pd.DataFrame(records, columns=[...]) | From a list of dicts. Auto-derives columns. |
| df.dtypes / df.info() / df.describe(include="all") | Schema + summary. |
| df.shape / df.columns / df.index | Cardinality + labels. |
| df.head(10) / df.sample(5) / df.tail(20) | Peek subsets. |
| df.memory_usage(deep=True).sum() | Real footprint (incl. object strings). |
| df.set_index("id", drop=True) / df.reset_index(drop=False) | Promote / demote a column to/from the index. |
| df.rename(columns={"old": "new"}, errors="raise") | Safe rename — errors if a key is missing. |
| df.assign(rev = lambda d: d.qty * d.price) | Add a column in a chain. Preferred over df["rev"] = .... |
Nullable + Arrow-backedDtypes
| df.astype("int64[pyarrow]") | Arrow-backed integer. Nullable, fast, compact. |
| df.astype({"a":"float32", "b":"string[pyarrow]"}) | Per-column cast. |
| df.convert_dtypes(dtype_backend="pyarrow") | Best-effort widen to nullable Arrow types. |
| pd.read_csv("...", dtype_backend="pyarrow") | Skip the object → string conversion on load. |
| pd.Categorical([...], categories=[...], ordered=True) | Low-cardinality column — cuts memory + speeds groupby. |
| df["k"] = df["k"].astype("category") | Promote in place. |
| df["ts"] = pd.to_datetime(df["ts"], utc=True, errors="coerce") | Parse. errors="coerce" turns bad rows into NaT. |
| df["n"] = pd.to_numeric(df["n"], errors="coerce") | Force numeric; non-parseable → NaN. |
| pd.NA | Missing scalar for nullable dtypes. Different from np.nan. |
loc · iloc · querySelecting rows & columns
| df["a"] / df[["a","b"]] | Column(s) by label. |
| df.loc[mask, ["a","b"]] | Label-based slice with boolean mask. Preferred over chained df[mask]["a"]. |
| df.loc[5:10, "a":"c"] | Inclusive label slicing on rows AND columns. |
| df.iloc[0:5, 0:3] | Integer positions. Half-open like Python slicing. |
| df.at[5, "price"] / df.iat[0, 2] | Single-cell access — fastest scalar getter. |
| df.query("price > 100 and tag in ['a','b']") | String filter. Supports @var for outer names. |
| df.filter(like="price") / filter(regex=r"^px_") | Pick columns / rows by name pattern. |
| df.isin({"k": ["a","b"]}) | Membership filter. |
| df.between(lo, hi, inclusive="both") | Range membership on a Series. |
| df.where(mask, other) / df.mask(cond, other) | Conditional replace — where keeps when True, mask replaces when True. |
NaN / NA handlingMissing data
| df.isna() / df.notna() | Boolean mask. |
| df.isna().sum() / df.isna().mean() | Counts / fractions of missing per column. |
| df.dropna(subset=["a"], how="any") | Drop rows missing in specific columns. |
| df.fillna({"a": 0, "b": "unknown"}) | Per-column fill. |
| df["a"].ffill() / df["a"].bfill() | Forward / backward fill. Preferred over deprecated method= kwarg. |
| df["a"].interpolate(method="time") | Time-aware interpolation on a datetime index. |
| df.replace({-1: pd.NA, "?": pd.NA}) | Sentinel → NA. |
| df.duplicated(subset=["k"], keep="first") | Mark duplicates. |
| df.drop_duplicates(subset=["k"], keep="last") | Dedupe. |
Split · apply · combineGroupBy
| df.groupby("k")["x"].sum() | Single agg. |
| df.groupby(["k1","k2"], observed=True).size() | Multi-key. observed=True skips empty Categorical combos. |
| df.groupby("k").agg(total=("x","sum"), avg=("y","mean")) | Named aggregation. Preferred — clean column names. |
| df.groupby("k").transform("mean") | Same-shape output. Broadcast group stats back to rows. |
| df.groupby("k").filter(lambda g: len(g) >= 10) | Keep / drop entire groups. |
| df.groupby("k", as_index=False)["x"].mean() | Keep grouping cols as columns, not the index. |
| df.groupby("k", sort=False) | Skip sorting groups — faster on huge keys. |
| df.groupby("k", dropna=False) | Keep NaN as its own group. |
| df.groupby(pd.Grouper(freq="W")) | Time bucketing on a datetime index. |
| .apply(fn, include_groups=False) | Last-resort generic apply. include_groups=False (2.2+) avoids the key-as-column warning. |
import pandas as pd
df = pd.DataFrame({
"store": ["a","a","b","b","b"],
"item": ["x","y","x","y","x"],
"qty": [3, 1, 2, 5, 4],
"price": [10, 5, 12, 7, 11],
})
# Single agg, single column
df.groupby("store")["qty"].sum() # Series indexed by store
# Multiple aggs, multiple columns — named aggregation (clean column names)
out = df.groupby("store").agg(
total_qty = ("qty", "sum"),
avg_price = ("price", "mean"),
rows = ("item", "count"),
)
# Transform: same shape as input — broadcast group stats back to rows
df["qty_share"] = df["qty"] / df.groupby("store")["qty"].transform("sum")
# Filter groups by a predicate on the group as a whole
df.groupby("store").filter(lambda g: g["qty"].sum() > 5)
# Apply arbitrary func per group (slower, last resort)
df.groupby("store").apply(
lambda g: g.sort_values("price").head(1),
include_groups=False, # 2.2+: don't pass keys
)
# Pivot table — group + reshape in one step
df.pivot_table(index="store", columns="item",
values="qty", aggfunc="sum", fill_value=0)
pivot · melt · stackReshape & pivot
| df.pivot(index="day", columns="sku", values="qty") | Long → wide. Errors on duplicates. |
| df.pivot_table(index="d", columns="s", values="q", aggfunc="sum") | Aggregate during pivot. Handles duplicates. |
| df.melt(id_vars=["id"], var_name="metric", value_name="v") | Wide → long. |
| df.stack(future_stack=True) | Columns → row MultiIndex. future_stack=True opts into 3.0 semantics. |
| df.unstack(level=-1) | Index level → columns. |
| pd.crosstab(df["a"], df["b"], values=df["v"], aggfunc="mean") | Contingency table / cross-tab. |
| pd.wide_to_long(df, stubnames=["px_"], i="id", j="period") | Wide → long with column-name parsing. |
| df.explode("tags") | List-valued column → one row per element. |
| pd.get_dummies(df, columns=["k"], drop_first=True) | One-hot encoding. |
merge · concat · asofMerge, join, concat
| a.merge(b, on="id", how="left", validate="m:1") | SQL-style join. validate = 1:1, 1:m, m:1, m:m. |
| a.merge(b, how="outer", indicator=True) | _merge column shows row origin (left_only / right_only / both). |
| a.merge(b, left_index=True, right_on="id") | Index ↔ column join. |
| a.join(b, how="left") | Index-aligned join. Shorthand for merge on indexes. |
| pd.concat([a, b], axis=0, ignore_index=True) | Stack rows. axis=1 stacks columns. |
| pd.concat([a, b], keys=["x","y"]) | Add a top-level MultiIndex tagging origin. |
| pd.merge_asof(left, right, on="ts", by="symbol", tolerance=...) | Nearest-in-time join. Both sides must be sorted on the key. |
| a.combine_first(b) | Take from a; fill NaNs from b. |
import pandas as pd
orders = pd.DataFrame({"order_id":[1,2,3,4], "customer_id":[10,11,10,99]})
cust = pd.DataFrame({"customer_id":[10,11,12], "name":["Ana","Bob","Cy"]})
# Inner join — only matched rows
orders.merge(cust, on="customer_id")
# Left join — keep all orders; missing customers become NaN
orders.merge(cust, on="customer_id", how="left", indicator=True)
# _merge column tells you which side a row came from
# Different key names left vs right
orders.merge(cust, left_on="customer_id", right_on="customer_id")
# Many-to-many → validate to catch accidental row explosions
orders.merge(cust, on="customer_id", validate="m:1") # raises if violated
# Asof join — match each row to the *most recent* key on the right
trades = pd.DataFrame({
"ts":[pd.Timestamp("09:00:01"), pd.Timestamp("09:00:03")],
"ticker":["A","A"], "qty":[100, 200],
}).sort_values("ts")
quotes = pd.DataFrame({
"ts":[pd.Timestamp("09:00:00"), pd.Timestamp("09:00:02")],
"ticker":["A","A"], "px":[10.0, 10.5],
}).sort_values("ts")
pd.merge_asof(trades, quotes, on="ts", by="ticker",
direction="backward", tolerance=pd.Timedelta("1s"))
Datetime · resample · rollingTime series
| pd.to_datetime(df["ts"], utc=True, format="ISO8601") | Parse. Always go through UTC unless you have a reason. |
| pd.date_range("2026-01-01", periods=N, freq="D") | Build a DatetimeIndex. |
| df.set_index("ts")["2026-01"] | Partial-string slicing on a datetime index. |
| df.resample("W", label="right", closed="right").mean() | Time bucketing. Watch label + closed. |
| df.rolling("7D", min_periods=1).mean() | Time-aware rolling window (needs sorted datetime index). |
| df.expanding().mean() | Cumulative window from start. |
| df["ret"] = df["px"].pct_change() | Period-to-period returns. |
| df.shift(1, freq="D") | Calendar-aware shift — preserves business days when paired with BDay. |
| df.tz_localize("UTC").tz_convert("America/New_York") | Tag a naive series UTC, then view in a local zone. |
| df.asfreq("D", method="ffill") | Reindex to a frequency, filling gaps. |
import pandas as pd
import numpy as np
# Build a time index — tz-aware where it matters
idx = pd.date_range("2026-01-01", periods=90, freq="D", tz="UTC")
df = pd.DataFrame({"price": np.random.default_rng(0).normal(100, 5, 90).cumsum()},
index=idx)
# Partial-string slicing on a DatetimeIndex
df["2026-01"] # whole January
df["2026-01-15":"2026-02-15"] # any range
# Resample — group time-bucketed rows; pick label / closed convention
df.resample("W", label="right", closed="right").agg(
open=("price", "first"), high=("price", "max"),
low =("price", "min"), close=("price", "last"),
)
# Rolling window + min_periods to keep early rows
df["sma_7"] = df["price"].rolling(7, min_periods=1).mean()
df["zscore_7"] = (df["price"] - df["sma_7"]) / df["price"].rolling(7).std()
# Time-zone conversion
df_ny = df.tz_convert("America/New_York")
# Period vs Timestamp
pd.period_range("2026Q1", periods=4, freq="Q")
# Shift + diff — produce lags safely
df["ret"] = df["price"].pct_change() # = price.diff() / price.shift()
df["lag1"] = df["price"].shift(1)
.str accessorString operations
| s.str.lower() / s.str.strip() / s.str.title() | Vectorized stdlib methods. |
| s.str.contains(r"^err", regex=True, na=False) | Boolean mask. na=False excludes missing. |
| s.str.extract(r"(?P<area>\d{3})-(\d{4})") | First match → DataFrame of named groups. |
| s.str.extractall(r"(\d+)") | All matches → MultiIndex DataFrame. |
| s.str.replace(r"\s+", " ", regex=True) | Regex replace. |
| s.str.split(",", expand=True) | Split into separate columns. |
| s.str.cat(others, sep="-") | Vectorized concat. |
| s.astype("string[pyarrow]") | Cast to Arrow string — faster + nullable. |
CSV · Parquet · SQLI/O
| pd.read_csv("f.csv", usecols=[...], parse_dates=[...], dtype={...}) | Most common loader. Always specify dtype on big files. |
| pd.read_csv("...", chunksize=100_000) | Iterator of DataFrames — for files that don’t fit in RAM. |
| pd.read_parquet("f.parquet", engine="pyarrow", columns=["a","b"]) | Preferred binary format. Faster, schema-preserving. |
| df.to_parquet("f.parquet", compression="zstd", index=False) | Write. zstd beats snappy on size and is plenty fast. |
| pd.read_json("f.json", lines=True) | JSON Lines / NDJSON. |
| pd.read_excel("f.xlsx", sheet_name=0, dtype=...) | Excel. Needs openpyxl for .xlsx. |
| pd.read_sql(query, conn, parse_dates=[...]) | SQL. Pass a SQLAlchemy connectable. |
| df.to_sql("t", conn, if_exists="append", index=False, method="multi") | Bulk INSERT. method="multi" batches rows. |
| df.to_clipboard() / pd.read_clipboard() | Quick paste in/out of a spreadsheet. |
Load · clean · aggregate · persistEnd-to-end · Customer-month revenue
A typical wrangling pipeline — chained .pipe / .assign / .query, grouped agg, pivot, Parquet write. No mutation, no inplace=True.
# Load → clean → enrich → aggregate → persist. Chained, idiomatic, no copies.
import pandas as pd
orders = (
pd.read_csv("orders.csv",
parse_dates=["placed_at"],
dtype={"customer_id": "int64", "sku": "string"},
dtype_backend="pyarrow") # nullable, fast
.rename(columns=str.lower)
.dropna(subset=["customer_id", "placed_at"])
.assign(
month = lambda d: d["placed_at"].dt.to_period("M"),
revenue = lambda d: d["qty"] * d["unit_price"],
)
.query("status == 'paid' and revenue > 0")
)
# Customer × month revenue, top 10 customers by total spend
cust_month = (orders
.groupby(["customer_id", "month"], observed=True, as_index=False)
.agg(orders=("order_id", "nunique"),
revenue=("revenue", "sum"))
)
top10 = (cust_month
.groupby("customer_id")["revenue"].sum()
.nlargest(10).index
)
(cust_month[cust_month["customer_id"].isin(top10)]
.pivot(index="month", columns="customer_id", values="revenue")
.fillna(0)
.to_parquet("top10_monthly.parquet", compression="zstd"))
Best practiceGood to know
assign / query / pipe instead of mutating in place.
Chains read top-to-bottom, never trigger SettingWithCopyWarning, and play well with copy-on-write. inplace=True is being phased out anyway.
dtype + usecols on read_csv for big files.
Without them pandas re-scans the whole file to guess types and loads columns you’ll discard. Inferred object columns are slow downstream.
merge_asof for time-aligned joins.
Hand-rolled "most-recent-before" joins in Python loops are slow and bug-prone. merge_asof is C-fast and handles tolerance, direction, and by-keys.
Common trapsWatch out for
df[df.x > 0]["y"] = 1 writes to a temporary — the original isn’t updated. Always use df.loc[df.x > 0, "y"] = 1. Turn on copy-on-write to fail loudly.
groupby().apply is the slow path.
It re-enters Python per group. Try agg / transform / vectorized expressions first; only fall back to apply when you genuinely need per-group Python.
pd.to_datetime(s) tries every parser on every row. Pass format="ISO8601" (or the exact format) and you’ll see 10–100× speedups on large series.