DS DevShelfHub Projects · AI tools
Cheatsheets / Pandas
Cheatsheet · AI frameworks

Pandas: DataFrames, Indexing, Groupby, Merge and Time Series Reference Guide

By DevShelfHub

Series, DataFrames, indexing (.loc/.iloc), groupby, merge/join, reshape (pivot/melt), time series, I/O (CSV/Parquet/SQL), and type conversion — Pandas 2.x reference with Copy-on-Write semantics and pyarrow backend.

122 items 9 min DataFrames GroupBy Merge

Start hereQuick start · 6 you’ll reach for daily

Read CSVpd.read_csv("f.csv")
Filter rowsdf.query("price > 100")
Pick by labeldf.loc[mask, ["a","b"]]
Group + aggdf.groupby("k").agg(...)
Joina.merge(b, on="id", how="left")
Chain stepsdf.assign(...).query(...).pipe(fn)

Target versions · paceVersions

Targets: 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

bash
# 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 pdCanonical alias.
import numpy as npCompanion — df.values returns an ndarray.
from pandas import Timestamp, Timedelta, Period, DateOffsetTime-aware scalars + frequency math.
from pandas.api.types import is_numeric_dtype, is_datetime64_any_dtypeType predicates for runtime checks.
from pandas.tseries.offsets import BDay, MonthEnd, HourBusiness-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.indexCardinality + 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.NAMissing 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.
python
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.
python
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.
python
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.

python
# 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

Chain with 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.
Specify 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.
Reach for 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

Chained assignment silently sometimes works. 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.
Loading dates without a format does a slow guess. 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.

Go deeperSee also

Pandas FAQ

What is Pandas and what is it used for?

Pandas is the primary data manipulation and analysis library for Python. It provides DataFrame (2D labelled table) and Series (1D labelled array) data structures with built-in support for reading CSV/Excel/Parquet, handling missing values, groupby aggregations, merges, time series resampling, and vectorised string operations.

What is the difference between .loc and .iloc in Pandas?

.loc selects rows and columns by label — use it with index values and column names. .iloc selects by integer position — use it with 0-based row and column numbers. Both accept slices, lists, and boolean arrays. When in doubt, .loc is safer because integer-labelled indexes make .iloc and [] ambiguous.

How do I group and aggregate data in Pandas?

Use df.groupby("col").agg({"value": "sum", "count": "size"}) to group rows by a column and compute multiple aggregations. Chain .reset_index() to flatten the result back to a regular DataFrame. For named aggregations, use the syntax: df.groupby("col").agg(total=("value", "sum"), n=("id", "count")).

How do I merge two DataFrames in Pandas?

Use pd.merge(left, right, on="key", how="inner") for SQL-style joins — inner, left, right, or outer. Merge on multiple columns with on=["a","b"] or on mismatched names with left_on="id", right_on="user_id". Use df.join(other) when joining on the index. For appending rows use pd.concat([df1, df2], ignore_index=True).

How does Pandas handle missing data?

Pandas represents missing values as NaN (float) or pd.NA (nullable types). Detect with df.isna() or df.notna(). Fill with df.fillna(0) or forward-fill with df.ffill(). Drop rows with df.dropna(). Nullable integer and boolean dtypes (Int64, boolean) preserve NA without converting to float — use pd.array(dtype="Int64") or assign with .astype("Int64").

How do I read and write files with Pandas?

Read CSV with pd.read_csv("file.csv"), Excel with pd.read_excel("file.xlsx"), Parquet with pd.read_parquet("file.parquet"), and JSON with pd.read_json("file.json"). Write with df.to_csv(), df.to_parquet(), etc. For large files, use chunksize= with read_csv or the pyarrow backend (engine="pyarrow") for faster Parquet I/O.