Skip to content

Pandas Cheatsheet

Data analysis and manipulation library for Python.

01

Getting Started

Installation & Import

pandas is conventionally imported as pd, with numpy as np since pandas uses numpy arrays under the hood. The two core data structures are Series (1D labeled array) and DataFrame (2D labeled table). Almost all tabular data manipulation in Python starts here.

pandas
# Install pandas (pick one)
pip install pandas
conda install pandas

# Standard import convention
import pandas as pd
import numpy as np  # pandas relies heavily on numpy

# Check version
print(pd.__version__)

# Pandas is built on numpy: a Series is a 1D labeled array,
# a DataFrame is a 2D labeled table (dict of Series).

Series & DataFrame

A Series has a single column of values plus an index (labels). A DataFrame is a collection of Series sharing a common index — think of it as a spreadsheet or SQL table. You can build DataFrames from dicts (keys=columns), lists of lists, numpy arrays, or files.

pandas
import pandas as pd

# Series: 1D labeled array
s = pd.Series([10, 20, 30, 40], index=["a", "b", "c", "d"])
# a    10
# b    20
# ...

# DataFrame: 2D table (dict of columns)
df = pd.DataFrame({
    "name":  ["Alice", "Bob", "Charlie"],
    "age":   [25, 30, 35],
    "city":  ["NYC", "LA", "SF"],
})

# From list of lists with column names
df2 = pd.DataFrame([[1, 2], [3, 4]], columns=["a", "b"])

Creating a Sample DataFrame

For quick experiments, build a small DataFrame from a dict. For larger tests use numpy's random generators with a seed for reproducibility. date_range creates DatetimeIndex — the foundation for time-series work. Setting a meaningful index (dates, IDs) early makes later selection and joins much cleaner.

pandas
import pandas as pd
import numpy as np

# Quick test DataFrame with random data
df = pd.DataFrame({
    "id":     range(1, 6),
    "name":   ["Alice", "Bob", "Charlie", "David", "Eve"],
    "age":    [25, 30, 35, 28, 40],
    "score":  [85.5, 92.0, 78.3, 88.9, 95.2],
    "pass":   [True, True, False, True, True],
})

# Larger random DataFrame
rng = np.random.default_rng(42)
big = pd.DataFrame(rng.normal(size=(100, 4)), columns=list("ABCD"))

# Dates as index
dates = pd.date_range("2024-01-01", periods=5)
ts = pd.DataFrame({"value": [1, 2, 3, 4, 5]}, index=dates)

Reading Data from Files

read_csv is the workhorse — always specify sep, encoding, parse_dates, and index_col when relevant to avoid post-load cleaning. Parquet is far faster and smaller than CSV for large datasets and preserves dtypes. For SQL, SQLAlchemy engines handle connection pooling. read_clipboard is surprisingly handy for quick copy-paste analysis.

pandas
import pandas as pd

# CSV — the most common
df = pd.read_csv("data.csv")
df = pd.read_csv("data.csv", sep=";", encoding="utf-8",
                 parse_dates=["date"], index_col="id")

# Excel (needs openpyxl)
df = pd.read_excel("data.xlsx", sheet_name="Sheet1")

# JSON
df = pd.read_json("data.json")

# Parquet (fast columnar format, great for large data)
df = pd.read_parquet("data.parquet")

# SQL (needs SQLAlchemy)
from sqlalchemy import create_engine
engine = create_engine("sqlite:///my.db")
df = pd.read_sql("SELECT * FROM users", engine)

# Clipboard (paste a table from a spreadsheet)
df = pd.read_clipboard()

Saving Data

Always pass index=False when saving to CSV/Excel unless your index carries meaningful data — otherwise you get a spurious 'Unnamed: 0' column on reload. Parquet preserves dtypes across save/load (CSV doesn't). Use if_exists='append' to add rows to an existing SQL table, 'replace' to overwrite.

pandas
import pandas as pd

# CSV (no index by default is usually what you want)
df.to_csv("output.csv", index=False)
df.to_csv("output.csv", index=False, encoding="utf-8-sig")  # Excel-friendly

# Excel (needs openpyxl)
df.to_excel("output.xlsx", sheet_name="Sheet1", index=False)

# Parquet (preserves dtypes, fast, compressed)
df.to_parquet("output.parquet")

# JSON
df.to_json("output.json", orient="records", indent=2)

# SQL
df.to_sql("users", engine, if_exists="replace", index=False)

# Multiple sheets to one Excel file
with pd.ExcelWriter("multi.xlsx") as writer:
    df1.to_excel(writer, sheet_name="sheet1", index=False)
    df2.to_excel(writer, sheet_name="sheet2", index=False)
02

Data Inspection

Viewing Data: head, tail, sample

head/tail show the edges; sample shows random rows, which is more representative for sanity-checking distributions and formats. Use set_option to disable truncation when inspecting wide DataFrames — defaults hide columns and rows. Always look at sample data before any analysis to catch encoding issues, sentinel values, and type mismatches early.

pandas
import pandas as pd

# First/last rows
df.head()        # first 5 (default)
df.head(10)      # first 10
df.tail()        # last 5
df.tail(3)       # last 3

# Random sample (great for sanity checks)
df.sample(5)                    # 5 random rows
df.sample(frac=0.1, random_state=42)  # 10% of rows
df.sample(n=5, replace=True)    # sample with replacement (bootstrap)

# See the raw values (not truncated)
pd.set_option("display.max_rows", 200)
pd.set_option("display.max_columns", None)
pd.set_option("display.width", 1000)

Structure & Dtypes

info() is the single most useful inspection call — it shows dtypes, non-null counts per column, and memory. Watch for object dtype (usually strings stored inefficiently); converting to 'category' or 'string' dtype saves memory. memory_usage(deep=True) reveals the true cost of object columns, which is often much larger than reported.

pandas
# Shape and basic structure
df.shape         # (rows, cols)
df.size          # rows * cols
df.ndim          # 2 for DataFrame, 1 for Series
len(df)          # number of rows

# Column names and dtypes
df.columns       # column labels
df.index         # row labels
df.dtypes        # dtype of each column
df.info()        # concise summary: dtypes, non-null counts, memory

# Memory usage (helpful for big data)
df.memory_usage(deep=True)
df.info(memory_usage="deep")

Descriptive Statistics

describe() gives a fast statistical summary — watch for mismatched count (missing values) and suspicious min/max. value_counts() is essential for categorical columns (shows frequency and class balance). corr() defaults to Pearson; pass method='spearman' for rank correlation. Always use numeric_only=True on mixed-type frames to avoid errors.

pandas
# Numeric summary (count, mean, std, min, quartiles, max)
df.describe()
df.describe(include="all")   # include non-numeric too
df.describe(percentiles=[.1, .5, .9])

# Per-column stats
df["age"].mean()
df["age"].median()
df["age"].std()
df["age"].var()
df["age"].min(), df["age"].max()
df["age"].quantile(0.95)

# Non-numeric summaries
df["city"].value_counts()
df["city"].unique()
df["city"].nunique()

# Correlation and covariance
df.corr(numeric_only=True)
df.cov(numeric_only=True)

Unique Values & Counts

value_counts is one of the most-used pandas methods — pair it with normalize=True for proportions and dropna=False to check missingness. crosstab builds a frequency table of two categoricals, useful for chi-square tests and confusion matrices. Binning continuous columns before value_counts reveals distribution shape.

pandas
# Unique values
df["city"].unique()              # array of unique values
df["city"].nunique()             # count of unique
df["city"].nunique(dropna=False) # include NaN

# Value counts (frequency table)
df["city"].value_counts()
df["city"].value_counts(normalize=True)   # proportions
df["city"].value_counts(ascending=True)
df["city"].value_counts(dropna=False)     # count NaN too

# Cross tabulation (frequency table of two columns)
pd.crosstab(df["city"], df["pass"])

# Binning then counting
df["age_group"] = pd.cut(df["age"], bins=[0, 18, 30, 50, 100],
                          labels=["child", "young", "adult", "senior"])
df["age_group"].value_counts()

Checking for Missing Values

Always check missingness before analysis — isnull().sum() per column is the standard first look. Pay attention to columns with high missing fractions; they may need dropping or a different handling strategy. Rows with missing values in critical columns can be filtered with df[df.isnull().any(axis=1)]. The missingno library visualizes missingness patterns to spot structural data issues.

pandas
# Count missing per column
df.isnull().sum()
df.isna().sum()           # alias

# Fraction missing
df.isnull().mean().mul(100).round(2)  # % missing per column

# Total missing
df.isnull().sum().sum()

# Rows with any missing
df[df.isnull().any(axis=1)]

# Non-null counts
df.notnull().sum()

# Visualize missingness pattern
# (requires missingno: import missingno as msno; msno.matrix(df))
03

Series Basics

Creating Series

A Series is a 1D array with labels (the index). The index lets you look up values by label, not just position. Specifying smaller dtypes (int8, float32) saves memory on large data — int8 holds -128..127, plenty for booleans-as-ints or small codes. Dict keys naturally become the index.

pandas
import pandas as pd
import numpy as np

# From a list
s = pd.Series([1, 2, 3, 4])

# From a list with custom index
s = pd.Series([10, 20, 30], index=["a", "b", "c"])

# From a dict (keys become index)
s = pd.Series({"a": 1, "b": 2, "c": 3})

# From a numpy array
s = pd.Series(np.random.randn(5))

# Scalar broadcast to an index
s = pd.Series(5, index=["a", "b", "c"])  # [5, 5, 5]

# Specify dtype
s = pd.Series([1, 2, 3], dtype="float64")
s = pd.Series([1, 2, 3], dtype="int8")    # save memory

Indexing & Selection

Label-based slicing (s['a':'c']) is INCLUSIVE on both ends — a common gotcha vs Python's positional slicing which is exclusive. Use .loc for labels and .iloc for positions to avoid ambiguity, especially with integer indexes. The default s[...] is overloaded (label or position depending on dtype), so explicit .loc/.iloc is safer in production code.

pandas
s = pd.Series([10, 20, 30, 40], index=["a", "b", "c", "d"])

# By label (index)
s["a"]            # 10
s[["a", "c"]]     # multiple labels -> Series
s["a":"c"]        # slice INCLUSIVE of both ends (label-based)

# By position
s.iloc[0]         # first by position
s.iloc[-1]        # last
s.iloc[1:3]       # EXCLUSIVE of end (positional, like Python)

# By label with .loc (explicit)
s.loc["a"]
s.loc["a":"c"]    # inclusive

# Boolean indexing
s[s > 20]
s[(s > 10) & (s < 40)]

Vectorized Operations

Operations on Series are vectorized (no Python loops) and align on index — when adding two Series, pandas matches labels and fills NaN where labels don't overlap. This index alignment is pandas' killer feature but can surprise you; use .values to operate on raw numpy arrays if you want pure positional math. Vectorized ops are 100-1000x faster than iterrows loops.

pandas
s = pd.Series([1, 2, 3, 4])

# Element-wise arithmetic (aligns on index)
s + 10          # [11, 12, 13, 14]
s * 2
s ** 2
1 / s           # [1.0, 0.5, 0.333, 0.25]

# Between two Series (aligns on index, NaN for mismatches)
s1 = pd.Series({"a": 1, "b": 2, "c": 3})
s2 = pd.Series({"b": 20, "c": 30, "d": 40})
s1 + s2         # a=NaN, b=22, c=33, d=NaN

# Comparison and boolean
s > 2           # [False, False, True, True]
s.between(2, 3) # inclusive

# Math methods
s.sum(), s.mean(), s.std(), s.cumsum()

Useful Series Methods

sort_values and rank are everyday tools. drop_duplicates keeps the first occurrence by default; use keep='last' for the last. clip is handy for capping outliers before plotting. Most methods skip NaN by default; pass skipna=False to propagate NaN. Many methods return a new Series (pandas is mostly immutable), so chain them: s.dropna().sort_values().head(10).

pandas
s = pd.Series([3, 1, 4, 1, 5, 9, 2, 6, np.nan])

# Sorting
s.sort_values()                  # ascending
s.sort_values(ascending=False)
s.sort_index()

# Ranking
s.rank(method="average")         # 'min','max','first','dense'

# Unique, counts
s.unique()
s.nunique()
s.value_counts()
s.duplicated()                   # boolean: True if seen before
s.drop_duplicates()

# Statistical
s.sum(), s.prod(), s.cumsum(), s.cummax()
s.abs()
s.round(2)
s.clip(lower=0, upper=10)        # cap values to a range
s.quantile([0.25, 0.5, 0.75])

Handling NaN in Series

fillna has many strategies: constant, statistic (mean/median), forward/backward fill (great for time series), or interpolation. ffill/bfill propagate last known value — ideal for sensor or stock data that shouldn't jump. On read, use na_values to convert sentinels ('?', -999) to NaN so they're handled uniformly. interpolate supports linear, time, and index-based methods.

pandas
s = pd.Series([1.0, 2.0, np.nan, 4.0, np.nan])

# Detect
s.isna()             # boolean mask
s.notna()
s.isna().sum()       # count of NaN

# Drop
s.dropna()           # remove NaN

# Fill
s.fillna(0)                       # constant
s.fillna(s.mean())                # with mean
s.fillna(method="ffill")          # forward fill (old API)
s.ffill()                         # forward fill (new)
s.bfill()                         # backward fill
s.interpolate()                   # linear interpolation

# Replace sentinel values with NaN on read
# pd.read_csv("x.csv", na_values=["?", "N/A", ""])
04

Selecting & Filtering

Selecting Columns

Bracket notation df['col'] always works; dot notation df.col is convenient but fails for names with spaces or that collide with methods (like 'count'). filter(like=) and select_dtypes are clean ways to grab column groups without listing each name — invaluable on wide DataFrames with hundreds of columns.

pandas
# Single column (returns Series)
df["age"]
df.age            # only if name is a valid Python identifier

# Multiple columns (returns DataFrame)
df[["name", "age", "city"]]

# Columns by pattern
df.filter(like="age")               # columns containing "age"
df.filter(regex="^\d{4}$")          # columns matching regex
df.filter(items=["name", "age"])    # specific columns

# Select by dtype
df.select_dtypes(include="number")
df.select_dtypes(include=["int64", "float64"])
df.select_dtypes(exclude="object")
df.select_dtypes(include="datetime")

loc vs iloc

The #1 pandas gotcha: .loc uses labels (inclusive slices), .iloc uses positions (exclusive slices). For assignment, ALWAYS use .loc/iloc — chained indexing like df[df.a<5]['b']=0 raises SettingWithCopyWarning and may silently fail. .loc supports boolean masks (label-based filtering), which is the clean way to filter and assign.

pandas
# .loc: label-based (inclusive slices)
df.loc[5]                         # row with index label 5
df.loc[5, "name"]                 # row 5, column 'name'
df.loc[5:10, ["name", "age"]]     # rows 5-10 (inclusive), 2 cols
df.loc[df["age"] > 30]            # boolean mask

# .iloc: position-based (exclusive slices)
df.iloc[0]                        # first row
df.iloc[-1]                       # last row
df.iloc[0:5]                      # first 5 rows (exclusive)
df.iloc[0:5, 0:2]                 # first 5 rows, first 2 cols
df.iloc[:, 0]                     # all rows, first column

# Setting values (use .loc for assignment!)
df.loc[df["age"] < 0, "age"] = 0  # cap negatives to 0

# AVOID chained indexing: df[df["a"]<5]["b"] = 0  # SettingWithCopyWarning

Boolean Filtering

When combining conditions, use & | ~ with parentheses around each condition — Python's and/or don't work on boolean arrays. isin is the clean way to filter by multiple values (faster than chained ==). String filtering uses the .str accessor. Negate with ~ (bitwise NOT). All these return a new filtered DataFrame; assign to a variable to keep it.

pandas
# Single condition
df[df["age"] > 30]
df[df["city"] == "NYC"]

# Multiple conditions (use & | ~, NOT and/or/not; parenthesize!)
df[(df["age"] > 25) & (df["city"] == "NYC")]
df[(df["city"] == "NYC") | (df["city"] == "LA")]
df[~df["pass"]]                       # NOT pass

# isin for membership
df[df["city"].isin(["NYC", "LA", "SF"])]

# String methods in filters
df[df["name"].str.startswith("A")]
df[df["name"].str.contains("li", case=False)]

# Between
df[df["age"].between(25, 35, inclusive="both")]

# Null checks
df[df["score"].isnull()]
df[df["score"].notnull()]

query() Method

query() reads like SQL and avoids the parentheses-everywhere requirement of boolean indexing. Use @ to reference local Python variables. For large DataFrames, query can be faster because it evaluates the expression with numexpr, avoiding the creation of intermediate boolean arrays. It's especially nice inside groupby chains and pipelines.

pandas
# query() — string-based filtering, often more readable
df.query("age > 30")
df.query("age > 25 and city == 'NYC'")
df.query("age > 25 and city in ['NYC', 'LA']")
df.query("age > 25 or score > 90")

# Refer to Python variables with @
cities = ["NYC", "LA"]
df.query("city in @cities")

# Compare columns
df.query("score > age")

# Negation
df.query("not pass")

# query is faster than boolean indexing on large frames
# because it uses numexpr to avoid intermediate arrays

Sampling & Sampling Weights

sample(frac=0.8) followed by drop(train.index) is a clean train/test split that preserves the original index. For stratified sampling (preserving class proportions), sklearn's train_test_split is more reliable than manual pandas code. Sampling with weights is useful for importance sampling or simulating biased data.

pandas
# Random sample
df.sample(n=5)                          # 5 rows
df.sample(frac=0.1)                     # 10% of rows
df.sample(n=5, random_state=42)         # reproducible

# Sample with weights
df.sample(n=5, weights="score")         # higher score -> more likely

# Sample with replacement (bootstrap)
df.sample(n=len(df), replace=True)

# Train/test split
train = df.sample(frac=0.8, random_state=42)
test = df.drop(train.index)

# Stratified sample (preserve class proportions)
from sklearn.model_selection import train_test_split
train, test = train_test_split(df, test_size=0.2,
                                stratify=df["city"], random_state=42)
05

Data Cleaning

Handling Missing Data

dropna(how='all') drops only fully-empty rows; thresh keeps rows with at least N non-null values. Filling per-column with a dict is cleaner than multiple fillna calls. For time series, ffill/bfill and interpolate preserve continuity. Always consider whether missingness itself is informative — adding an 'is_missing' indicator column before imputing can preserve that signal for ML.

pandas
# Drop rows with any missing
df.dropna()                       # any NaN in any column
df.dropna(how="all")              # only if ALL columns NaN
df.dropna(subset=["age", "city"]) # only check these columns
df.dropna(thresh=3)               # keep rows with >=3 non-NaN

# Fill missing
df.fillna(0)
df.fillna({"age": df["age"].mean(),
           "city": "Unknown"})
df["age"] = df["age"].fillna(df["age"].median())

# Forward/backward fill (for time series)
df.ffill()
df.bfill()

# Interpolate
df.interpolate(method="linear")
df.interpolate(method="time")     # for DatetimeIndex

# Flag rows that were imputed
df["age_was_missing"] = df["age"].isnull()

Removing Duplicates

duplicated() marks rows that repeat an earlier row; keep='last' to mark the first occurrence instead. keep=False marks every duplicate row (including the first), useful when you want to inspect all conflicting rows. drop_duplicates returns a new DataFrame; pass inplace=True or reassign to modify. subset lets you dedupe on key columns like email or ID.

pandas
# Find duplicates
df.duplicated()                   # True for rows that are duplicates of earlier
df.duplicated().sum()             # count
df[df.duplicated()]               # view duplicates

# Drop duplicates
df.drop_duplicates()              # full-row duplicates
df.drop_duplicates(subset=["email"])   # dup on email only
df.drop_duplicates(subset=["name", "dob"], keep="last")  # keep last occurrence
df.drop_duplicates(keep=False)    # drop ALL duplicates (keep none)

# Find rows where a column is duplicated
df[df.duplicated(subset=["email"], keep=False)]
# .keep=False marks ALL duplicates (including first)

Renaming & Reordering

rename with a columns dict is the safest way to rename — it only renames listed columns and leaves others untouched. Reordering columns is just selection in the desired order. set_index followed by reset_index(drop=True) is the clean way to replace a messy index. Use str.strip on column names early — invisible trailing spaces cause endless 'KeyError' headaches.

pandas
# Rename columns
df.rename(columns={"old_name": "new_name"})
df.rename(columns=str.upper)        # uppercase all
df.rename(columns=str.strip)        # strip whitespace

# Replace all column names
df.columns = ["id", "name", "age"]

# Add prefix/suffix
df.add_prefix("col_")
df.add_suffix("_v2")

# Reorder columns
df[["name", "age", "city"]]          # select in new order

# Reorder rows by index
df.sort_index()

# Reset / set index
df.reset_index(drop=True)            # drop old index
df.set_index("id")                   # use 'id' as index

# Swap rows and columns
df.transpose()  # or df.T

Type Conversion

astype fails on unparseable values; pd.to_numeric with errors='coerce' turns bad values into NaN instead — much safer for messy data. 'category' dtype saves huge memory for low-cardinality strings. Downcasting (int64->int8) can cut memory 8x for columns with small value ranges. Always parse dates with an explicit format string for speed and correctness.

pandas
# Convert a column's dtype
df["age"] = df["age"].astype(int)
df["price"] = df["price"].astype(float)
df["pass"] = df["pass"].astype(bool)
df["city"] = df["city"].astype("category")    # save memory

# Convert with pd.to_numeric (handles bad values)
df["price"] = pd.to_numeric(df["price"], errors="coerce")  # bad -> NaN
df["price"] = pd.to_numeric(df["price"], errors="ignore")  # leave as-is

# Parse dates
df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d")

# Convert multiple columns at once
df = df.astype({"age": "int8", "score": "float32", "city": "category"})

# Downcast to save memory
df["age"] = pd.to_numeric(df["age"], downcast="integer")

Replacing Values

replace is the workhorse for swapping sentinel values (-999, '?') to NaN before analysis. map applies a dict lookup element-wise (unmatched values become NaN). where keeps values where the condition is True and replaces the rest; mask is the inverse. These all return new objects — reassign to apply.

pandas
# Replace specific values
df["city"].replace({"NYC": "New York", "LA": "Los Angeles"})
df.replace(-999, np.nan)              # whole frame
df.replace(["?", "N/A", ""], np.nan)  # multiple sentinels

# Replace by condition (mask)
df.loc[df["age"] < 0, "age"] = np.nan

# Clip outliers
df["age"] = df["age"].clip(0, 120)

# Map values with a dict (like a lookup)
df["grade"] = df["score"].map({90: "A", 80: "B", 70: "C"})

# Where: keep where condition True, else replace
df["age"].where(df["age"] >= 0, 0)    # set negatives to 0

# Mask: opposite of where
df["age"].mask(df["age"] < 0, 0)
06

Data Transformation

apply & applymap

apply runs a Python function per element/row — flexible but slow (100x slower than vectorized ops). Always prefer vectorized operations (df['col'] + 1, .str.upper()) when possible. Use apply for logic that can't be vectorized (custom string parsing, conditional transformations referencing multiple columns). For row-wise work, axis=1 and access columns via row['col'].

pandas
# Apply a function to each element of a column (Series)
df["age"] = df["age"].apply(lambda x: x + 1)
df["name"] = df["name"].apply(str.upper)

# Apply with multiple args
df["score"] = df["score"].apply(lambda x, n: round(x, n), n=2)

# Apply a function to each ROW (axis=1) — use columns
df["full"] = df.apply(lambda row: f"{row['name']} ({row['age']})", axis=1)

# Apply to every element of a DataFrame (newer: .map)
df = df.map(lambda x: str(x).strip() if isinstance(x, str) else x)

# Vectorized alternative (MUCH faster than apply)
df["age"] = df["age"] + 1  # vectorized, prefer this
df["name"] = df["name"].str.upper()

Adding & Modifying Columns

np.where is the vectorized if-else — far faster than apply with a lambda. np.select handles multiple conditions like a case/when statement. assign is a chain-friendly way to add columns (great in pipelines). insert places a column at a specific position. Drop columns with drop(columns=[...]) — much clearer than del df['col'].

pandas
# Add a new column
df["full_name"] = df["first"] + " " + df["last"]
df["is_adult"] = df["age"] >= 18

# Conditional column with np.where
df["category"] = np.where(df["age"] >= 18, "adult", "minor")

# Multiple conditions with np.select
conditions = [df["age"] < 18, df["age"] < 65, df["age"] >= 65]
choices = ["minor", "adult", "senior"]
df["group"] = np.select(conditions, choices, default="unknown")

# insert at a specific position
df.insert(2, "score_pct", df["score"] / 100)  # at col index 2

# Assign multiple columns at once (returns new df)
df = df.assign(
    age_months=df["age"] * 12,
    score_squared=df["score"] ** 2,
)

# Drop columns
df.drop(columns=["temp", "junk"])

Binning & Discretization

cut divides the range into equal-width bins (may produce empty bins); qcut divides into equal-frequency bins (each bin has ~same count) — usually more useful for skewed data. right=False makes bins left-inclusive [0,18) instead of right-inclusive (0,18]. Binned columns become categorical dtype, perfect for groupby aggregation.

pandas
# Equal-width bins
df["age_bin"] = pd.cut(df["age"], bins=4)

# Custom bins with labels
df["age_group"] = pd.cut(
    df["age"],
    bins=[0, 18, 30, 50, 100],
    labels=["child", "young", "adult", "senior"],
    right=False,  # left-inclusive: [0, 18)
)

# Equal-frequency bins (quantiles)
df["income_quartile"] = pd.qcut(df["income"], q=4, labels=["Q1","Q2","Q3","Q4"])

# Get bin edges
df["age_bin"].value_counts(sort=False)
pd.cut(df["age"], bins=4).cat.categories  # see the bin ranges

String Operations (.str accessor)

The .str accessor vectorizes Python string methods — never loop with apply(str.upper). split with expand=True turns one column into multiple (great for splitting names). extract with named groups pulls structured data out of text. All .str methods return NaN for NaN inputs and accept na= to control that behavior.

pandas
# All vectorized string methods via .str
df["name"] = df["name"].str.upper()
df["name"] = df["name"].str.lower()
df["name"] = df["name"].str.strip()           # remove leading/trailing
df["name"] = df["name"].str.replace(" ", "_")
df["name"] = df["name"].str.title()           # Title Case

# Split
df["name"].str.split(" ")                     # list per row
df[["first", "last"]] = df["name"].str.split(" ", expand=True)

# Contains / startswith / match (returns boolean)
df["name"].str.contains("li", case=False, na=False)
df["name"].str.startswith("A")
df["name"].str.match(r"^A\w+$")              # regex match

# Extract with regex groups
df["name"].str.extract(r"(?P<first>\w+)\s+(?P<last>\w+)")

# Length
df["name"].str.len()

Dates & Times (.dt accessor)

Always convert date columns with pd.to_datetime first, then use .dt to access components. The .dt accessor unlocks year/month/day/weekday/hour and timezone operations. Pandas can compare dates to strings ('2024-01-01') directly. Timezone-aware datetime work uses tz_localize (add tz) and tz_convert (change tz) — don't mix naive and aware datetimes.

pandas
df["date"] = pd.to_datetime(df["date"])

# Extract parts
df["year"]      = df["date"].dt.year
df["month"]     = df["date"].dt.month
df["day"]       = df["date"].dt.day
df["weekday"]   = df["date"].dt.day_name()    # 'Monday'
df["hour"]      = df["date"].dt.hour
df["is_weekend"] = df["date"].dt.dayofweek >= 5

# Date arithmetic
df["age_days"] = (pd.Timestamp("today") - df["date"]).dt.days

# Format back to string
df["date_str"] = df["date"].dt.strftime("%Y-%m-%d")

# Timezone handling
df["date_utc"] = df["date"].dt.tz_localize("UTC")
df["date_local"] = df["date_utc"].dt.tz_convert("US/Eastern")

# Filter by date
df[df["date"] > "2024-01-01"]
df[df["date"].between("2024-01-01", "2024-12-31")]
07

GroupBy & Aggregation

Basic GroupBy

groupby follows split-apply-combine: split rows by group key, apply a function per group, combine results. groupby('city')['age'].mean() returns a Series indexed by city; agg with a list returns a DataFrame. size() counts all rows per group (including NaN keys); value_counts() excludes NaN and sorts by frequency. Multiple group keys create a hierarchical index.

pandas
# Split-Apply-Combine: group rows, compute per group
df.groupby("city")["age"].mean()         # one column, one agg
df.groupby("city")["age"].agg(["mean", "median", "std"])

# Multiple columns
df.groupby("city")[["age", "score"]].mean()

# Group by multiple columns (hierarchical)
df.groupby(["city", "pass"])["age"].mean()

# Quick counts per group
df.groupby("city").size()                # includes NaN groups
df["city"].value_counts()                # excludes NaN, sorted

# Iterate over groups
for name, group in df.groupby("city"):
    print(name, len(group))

Multiple Aggregations

Named aggregations (pandas 0.25+) are the cleanest way — output columns get the names you specify instead of a MultiIndex. The syntax is new_col=('source_col', 'function'). Custom functions via lambda work but are slower than built-ins (mean, sum, etc. are C-optimized). Always reset_index() if you want the group key as a regular column.

pandas
# Different aggregations per column
df.groupby("city").agg({
    "age":   ["mean", "median", "std"],
    "score": ["min", "max", "count"],
})

# Named aggregations (cleaner output, no MultiIndex)
result = df.groupby("city").agg(
    avg_age=("age", "mean"),
    median_age=("age", "median"),
    n_people=("age", "count"),
    max_score=("score", "max"),
)

# Custom aggregation function
df.groupby("city").agg(
    age_range=("age", lambda x: x.max() - x.min()),
)

# Reset index to get a flat DataFrame
result = result.reset_index()

transform & filter

transform is magical — it returns a Series the same length as the input, broadcasting each group's result to its members. Perfect for adding group-level statistics (mean per category) alongside original rows, or filling missing values with group means. filter keeps or drops entire groups based on a per-group boolean condition (e.g. drop cities with too few samples).

pandas
# transform: broadcast group result back to original shape
df["age_mean_city"] = df.groupby("city")["age"].transform("mean")
df["age_dev"] = df["age"] - df.groupby("city")["age"].transform("mean")

# Fill missing with group mean
df["score"] = df.groupby("city")["score"].transform(
    lambda s: s.fillna(s.mean()))

# filter: keep/drop entire groups based on a condition
# keep groups with more than 2 rows
big_groups = df.groupby("city").filter(lambda g: len(g) > 2)
# keep groups where average score > 80
df.groupby("city").filter(lambda g: g["score"].mean() > 80)

apply on Groups

apply on groupby is the most flexible (run any function per group) but the slowest — prefer built-in aggregations (mean, sum) or transform when possible. Returning a Series from your function creates a DataFrame with the Series index as columns. idxmax returns the index of the max — use it to look up other columns in the same row.

pandas
# Apply any function per group (most flexible)
def top_scorer(g):
    return g.nlargest(1, "score")

df.groupby("city").apply(top_scorer)
df.groupby("city").apply(lambda g: g.nlargest(2, "score"))

# Normalize within group
df.groupby("city").apply(
    lambda g: (g["score"] - g["score"].mean()) / g["score"].std()
)

# Multiple results per group
def summarize(g):
    return pd.Series({
        "count": len(g),
        "avg_score": g["score"].mean(),
        "top_name": g.loc[g["score"].idxmax(), "name"],
    })

df.groupby("city").apply(summarize)

# include_groups=False (newer pandas) to exclude group cols from g

Pivot Tables & Crosstab

pivot_table is groupby + unstack combined — perfect for turning long data into a matrix (e.g. average score by city × pass). margins=True adds row/column totals. crosstab is a specialized pivot for counting combinations (frequency tables); normalize='index' gives row proportions. Melt is the inverse, converting wide back to long format.

pandas
# pivot_table: groupby + reshape in one step
pd.pivot_table(df, values="score", index="city", columns="pass",
               aggfunc="mean")
pd.pivot_table(df, values="score", index="city", columns="pass",
               aggfunc=["mean", "count"], margins=True)

# Multiple values
pd.pivot_table(df, values=["age", "score"], index="city",
               aggfunc={"age": "mean", "score": "max"})

# crosstab: frequency table of two (or more) categoricals
pd.crosstab(df["city"], df["pass"])
pd.crosstab(df["city"], df["pass"], normalize="index")  # row %
pd.crosstab(df["city"], df["pass"], values=df["score"],
            aggfunc="mean")  # like a pivot

# Melt a pivot back to long
wide = pd.pivot_table(df, values="score", index="city", columns="pass")
long = wide.reset_index().melt(id_vars="city", var_name="pass",
                                value_name="score")
08

Merging & Joining

merge (SQL-style joins)

merge is pandas' SQL JOIN — how controls inner/left/right/outer/cross. Always specify on explicitly; if the key has different names use left_on/right_on. Use suffixes to disambiguate overlapping columns (default _x/_y is opaque). validate='m:1' (or '1:1','1:m','m:m') raises if the join cardinality is wrong — a great safeguard against accidental row duplication.

pandas
# Like SQL JOIN
pd.merge(df1, df2, on="id")                     # inner join (default)
pd.merge(df1, df2, on="id", how="left")         # left join
pd.merge(df1, df2, on="id", how="right")
pd.merge(df1, df2, on="id", how="outer")        # full outer
pd.merge(df1, df2, on="id", how="cross")        # cartesian product

# Different column names in each frame
pd.merge(df1, df2, left_on="user_id", right_on="id")

# Join on index
pd.merge(df1, df2, left_index=True, right_index=True)

# Suffixes for overlapping non-key columns
pd.merge(df1, df2, on="id", suffixes=("_left", "_right"))

# Validate to catch unexpected duplicates
pd.merge(df1, df2, on="id", validate="m:1")  # many-to-one expected

concat (stacking)

concat stacks DataFrames vertically (axis=0, default) or horizontally (axis=1). ignore_index=True discards the original indexes and creates a clean 0..n-1 — usually what you want when stacking. join='inner' keeps only shared columns (useful when frames have drifted). verify_integrity=True errors on duplicate indexes, a safety net for messy appends.

pandas
# Stack vertically (rows) — same columns
pd.concat([df1, df2])
pd.concat([df1, df2], ignore_index=True)   # reindex 0..n-1

# Stack horizontally (columns)
pd.concat([df1, df2], axis=1)

# Concat with keys (hierarchical index)
pd.concat([df1, df2], keys=["q1", "q2"])

# Append (deprecated; use concat)
# df1.append(df2)  # deprecated, use pd.concat

# Verify columns match when stacking
pd.concat([df1, df2], verify_integrity=True)  # error on dup index

# Join options for mismatched columns
pd.concat([df1, df2], join="inner")  # keep only shared columns
pd.concat([df1, df2], join="outer")  # keep all, NaN for missing

join (index-based)

join is syntactic sugar over merge for the common case of joining on indexes — slightly more concise when both frames already have meaningful indexes. For joining on columns, merge is clearer. join can join multiple DataFrames at once (pass a list), which is handy for combining several lookup tables onto a main frame.

pandas
# join is merge optimized for joining on the index
df1.join(df2)                          # join on index
df1.join(df2, how="left")              # left, right, inner, outer
df1.join([df2, df3])                   # join multiple at once

# join on a column (set index first)
df1.set_index("id").join(df2.set_index("id"), how="inner")

# Suffix for overlapping columns
df1.join(df2, lsuffix="_left", rsuffix="_right")

# When to use join vs merge:
# - join: keys are the index (faster, cleaner)
# - merge: keys are regular columns (more flexible)

combine & update

combine_first is the idiomatic way to fill missing values from another DataFrame — like a coalesce in SQL. update modifies df1 in place, overwriting only where df2 has non-NaN values. These are great for patching a master dataset with newer partial data. concat + drop_duplicates is a quick union/dedup, but for true set operations consider merge with indicator=True.

pandas
# combine_first: fill NaN in df1 with values from df2
df1.combine_first(df2)
# Where df1 is NaN, take df2's value; otherwise keep df1.

# update: overwrite df1 in place with non-NaN from df2
df1.update(df2)

# combine with a custom function
import numpy as np
df1.combine(df2, lambda s1, s2: np.minimum(s1, s2))

# Concat then drop duplicates (union with dedup)
pd.concat([df1, df2]).drop_duplicates()

Checking Merge Results

indicator=True is invaluable for debugging joins — it tells you which rows came from which side. A sudden jump in row count after a merge usually means duplicate keys in one frame; check with value_counts() on the key before joining. For simple 'filter df1 to rows whose key exists in df2' use isin (semi-join) — no actual merge needed.

pandas
# indicator=True adds a '_merge' column showing the source
merged = pd.merge(df1, df2, on="id", how="outer", indicator=True)
# _merge values: 'left_only', 'right_only', 'both'

# Find rows in df1 but not df2 (anti-join)
only_left = merged[merged["_merge"] == "left_only"]

# Find rows in both (intersection)
both = merged[merged["_merge"] == "both"]

# Detect unexpected row explosion (duplicate keys)
print(len(df1), len(df2), len(merged))
# if len(merged) >> max(len(df1), len(df2)) you have duplicate keys

# isin for a simple semi-join (filter df1 to keys in df2)
df1[df1["id"].isin(df2["id"])]            # semi-join
df1[~df1["id"].isin(df2["id"])]           # anti-join
09

Reshaping Data

melt (wide to long)

melt converts wide-format data (one row per entity, many measurement columns) to long format (one row per measurement), which is the tidy format preferred by most ML/stats libraries. id_vars are the identifier columns kept as-is; everything else becomes var/value pairs. Long format is easier to groupby, filter, and plot.

pandas
# melt: unpivot columns into rows
df_wide = pd.DataFrame({
    "id": [1, 2],
    "q1": [10, 20],
    "q2": [15, 25],
    "q3": [20, 30],
})

long = df_wide.melt(id_vars="id", var_name="quarter", value_name="score")
#    id quarter  score
# 0   1      q1     10
# 1   2      q1     20
# ...

# Melt only specific columns
df_wide.melt(id_vars="id", value_vars=["q1", "q2"])

# Keep the wide columns too (with identifier)
df_wide.melt(id_vars="id", value_vars=["q1","q2","q3"],
             var_name="quarter", value_name="score",
             ignore_index=True)

pivot (long to wide)

pivot reshapes long to wide without aggregation — it fails if there are duplicate (index, columns) pairs, in which case use pivot_table with an aggfunc. The column names become the pivot's columns, which can be confusing — reset_index() and columns.name=None clean up the result. pivot is the inverse of melt.

pandas
# pivot: long to wide (without aggregation)
df_long = pd.DataFrame({
    "id": [1, 1, 1, 2, 2, 2],
    "quarter": ["q1", "q2", "q3", "q1", "q2", "q3"],
    "score": [10, 15, 20, 30, 35, 40],
})

wide = df_long.pivot(index="id", columns="quarter", values="score")
# quarter  q1  q2  q3
# id
# 1        10  15  20
# 2        30  35  40

# pivot fails on duplicate index/value combos — use pivot_table then
df_long.pivot_table(index="id", columns="quarter",
                    values="score", aggfunc="sum")

# Restore a regular column from the index
wide = wide.reset_index()
wide.columns.name = None

stack & unstack

stack/unstack move levels between rows and columns on MultiIndex objects — powerful for reshaping grouped results. groupby with multiple keys produces a Series with a MultiIndex; unstack turns one level into columns for a matrix view. stack is the inverse. These are the building blocks for pivot tables under the hood.

pandas
# MultiIndex column example
df = pd.DataFrame({
    ("A", "x"): [1, 2],
    ("A", "y"): [3, 4],
    ("B", "x"): [5, 6],
})

# stack: turn column level into inner row index
stacked = df.stack()
# Now a Series with MultiIndex (row, col_level_2)

# unstack: inverse — turn row level into column level
stacked.unstack()

# Common use: reshape grouped result
g = df.groupby(["city", "year"])["score"].mean()
# city  year
# NYC   2023    80
# NYC   2024    85
# LA    2023    70

g.unstack("year")  # years become columns
#        2023  2024
# city
# NYC    80    85
# LA     70    NaN

Exploding Lists

explode turns a column of lists into one row per list element, duplicating the other columns — essential for nested data (JSON arrays, multi-tag records). It's the clean way to normalize list-valued columns before analysis. To go the other way (combine rows into a list), use groupby + apply(list).

pandas
# explode: turn list-like values into separate rows
df = pd.DataFrame({
    "id": [1, 2],
    "tags": [["python", "ml"], ["sql", "db", "etl"]],
})
#              id           tags
# 0             1  [python, ml]
# 1             2  [sql, db, etl]

exploded = df.explode("tags")
#    id    tags
# 0   1  python
# 0   1      ml
# 1   2     sql
# 1   2      db
# 1   2    etl

# Useful after str.split
df["tags"] = df["tags_str"].str.split(",")
exploded = df.explode("tags")

# Aggregate back: groupby + list
exploded.groupby("id")["tags"].apply(list)

MultiIndex Operations

MultiIndex (hierarchical index) lets you represent high-dimensional data in 2D. xs (cross-section) selects at a specific level without needing to specify all outer levels. swaplevel and sort_index(level=) reorder levels for different views. Most of the time, reset_index() flattens a MultiIndex back to regular columns, which is easier to work with.

pandas
# Create a MultiIndex DataFrame
idx = pd.MultiIndex.from_tuples([("NYC", 2023), ("NYC", 2024),
                                  ("LA", 2023)], names=["city", "year"])
df = pd.DataFrame({"score": [80, 85, 70]}, index=idx)

# Select by outer level
df.loc["NYC"]              # all NYC rows
df.xs("NYC", level="city") # same, more explicit

# Select by inner level
df.xs(2023, level="year")

# Swap levels
df.swaplevel()

# Sort by level
df.sort_index(level="year")

# Reset one level back to a column
df.reset_index(level="year")

# Flatten columns after a groupby
g = df.groupby(["city", "year"]).mean().reset_index()
10

Sorting & Ranking

Sorting Values

sort_values with a list of columns and matching ascending list sorts lexicographically — primary key first, then tiebreaker. na_position controls where NaNs go (default 'last'). Sorting is stable, so equal keys preserve original order. Sorting modifies the index order; reset_index(drop=True) gives a clean sequential index afterward.

pandas
# Sort by one column
df.sort_values("age")
df.sort_values("age", ascending=False)

# Sort by multiple columns (stable sort)
df.sort_values(["city", "age"], ascending=[True, False])
# city ascending, age descending within each city

# Put NaNs first or last
df.sort_values("score", na_position="first")

# In-place (modifies df)
df.sort_values("age", inplace=True)

# Sort by index
df.sort_index()
df.sort_index(axis=1)            # sort columns by name
df.sort_index(ascending=False)

# Reset index after sorting
df.sort_values("age").reset_index(drop=True)

Ranking

rank is essential for computing percentiles, top-N within groups, and non-parametric statistics. method='dense' gives 1,2,2,3 (no gaps) — great for medals or grades; 'first' breaks ties by appearance order. rank(pct=True) returns percentiles. groupby + rank computes within-group rankings (e.g. top seller per region).

pandas
# Default rank: average for ties (1, 2.5, 2.5, 4)
df["rank"] = df["score"].rank()

# Methods for handling ties
df["score"].rank(method="average")  # default
df["score"].rank(method="min")      # ties get the min rank
df["score"].rank(method="max")      # ties get the max rank
df["score"].rank(method="first")    # ties broken by order of appearance
df["score"].rank(method="dense")    # 1, 2, 2, 3 (no gaps)

# Ascending / descending
df["score"].rank(ascending=False)   # highest = rank 1

# Rank as percentile
df["pct"] = df["score"].rank(pct=True)

# Rank within groups
df["city_rank"] = df.groupby("city")["score"].rank(ascending=False)

nlargest & nsmallest

nlargest/nsmallest are optimized — they use a heap internally, so they're faster than sorting the whole frame when you only need a few rows (e.g. top 10 from a million). For 'top N per group', groupby + apply(nlargest) is the idiom, though it's slower than a full sort + groupby head on some data. keep controls tie-breaking ('first', 'last', 'all').

pandas
# Top N rows — faster than sort_values + head on large data
df.nlargest(5, "score")            # top 5 by score
df.nlargest(5, "score", keep="first")  # tie-breaking
df.nsmallest(3, "age")             # youngest 3

# Top N per group
df.groupby("city").apply(lambda g: g.nlargest(2, "score"))

# Bottom N with multiple columns
df.nsmallest(5, ["age", "score"])

# Equivalent sort_values (slower for big data)
df.sort_values("score", ascending=False).head(5)

Sorting Indexes & Columns

sort_index(axis=1) alphabetizes columns — handy after merges add new columns in arbitrary order. To put key columns first (like 'id', 'name') use list concatenation: front columns then the rest. Explicit column selection df[[...]] is the clearest way to reorder and is also how you subset columns.

pandas
# Sort the row index
df.sort_index()
df.sort_index(ascending=False)

# Sort columns alphabetically
df.sort_index(axis=1)
df.sort_index(axis=1, ascending=False)

# Reorder columns explicitly
df[["name", "age", "city", "score"]]

# Reorder to a pattern
cols = sorted(df.columns)
df = df[cols]

# Move specific columns to the front
front = ["id", "name"]
df = df[front + [c for c in df.columns if c not in front]]

Sorting with Keys

sort_values(key=) applies a function to the column before sorting — case-insensitive or length-based sorts. Ordered Categorical columns sort by their defined category order, not alphabetically — great for weekdays, sizes (S<M<L), or custom business orders. Natural sort (file1, file2, file10) needs a custom key that splits digits.

pandas
# Sort by a function of the values (key, like sorted())
df.sort_values("name", key=lambda s: s.str.lower())
# case-insensitive sort

# Sort by string length
df.sort_values("name", key=lambda s: s.str.len())

# Sort by a custom order (categorical)
df["city"] = pd.Categorical(df["city"],
    categories=["NYC", "LA", "SF"], ordered=True)
df.sort_values("city")  # follows the defined category order

# Natural sort (numbers in strings)
import re
def natural_key(s):
    return [int(t) if t.isdigit() else t for t in re.split(r"(\d+)", s)]
df.sort_values("version", key=lambda s: s.map(natural_key))
11

Datetime Operations

Parsing Dates

Always pass an explicit format string when you know it — it's 10x faster than inference and avoids ambiguity (01/02/2024 could be Jan 2 or Feb 1). errors='coerce' turns unparseable values into NaT (Not a Time, the datetime NaN). pd.to_datetime on a DataFrame with year/month/day columns constructs dates from parts.

pandas
# Parse strings to datetime
df["date"] = pd.to_datetime(df["date"])
df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d")  # faster + safer

# Handle mixed formats
df["date"] = pd.to_datetime(df["date"], format="mixed")

# Coerce bad values to NaT (datetime NaN)
df["date"] = pd.to_datetime(df["date"], errors="coerce")

# Parse on read
df = pd.read_csv("data.csv", parse_dates=["date", "created_at"])

# Parse multiple columns into one
df["datetime"] = pd.to_datetime(df[["year", "month", "day"]])

# Timestamp (scalar)
pd.Timestamp("2024-06-15 14:30:00")
pd.Timestamp.now()
pd.Timestamp("2024-06-15").tz_localize("UTC")

date_range & Periods

date_range generates DatetimeIndex sequences with flexible freq strings: 'D' (day), 'H' (hour), 'MS' (month start), 'W-MON' (weekly Mondays), 'Q' (quarter). bdate_range skips weekends. Period represents an interval (a whole month, a whole quarter) rather than an instant — useful for reporting periods. freq aliases are powerful: '2D' = every 2 days.

pandas
# Generate a sequence of dates
pd.date_range("2024-01-01", "2024-01-10")           # daily
pd.date_range("2024-01-01", periods=10)             # 10 days
pd.date_range("2024-01-01", periods=10, freq="H")   # hourly
pd.date_range("2024-01-01", "2024-12-31", freq="MS") # month start
pd.date_range("2024-01-01", "2024-12-31", freq="W-MON") # weekly Mondays

# Business days only
pd.bdate_range("2024-01-01", periods=10)

# Period (represents an interval, like a month)
p = pd.Period("2024-06", freq="M")
p.to_timestamp()    # convert to the month's start
p.start_time, p.end_time

# Period range
pd.period_range("2024-01", "2024-12", freq="M")

Resampling & Frequency Conversion

resample is groupby for time series — downsample with an aggregation (W/M/Q sums and means), upsample with a fill (ffill, interpolate). resample requires a DatetimeIndex. The 'label' and 'closed' arguments control whether the bin boundary belongs to the left or right period — important for weekly/monthly aggregations to avoid off-by-one errors.

pandas
ts = pd.Series(range(100), index=pd.date_range("2024-01-01", periods=100, freq="D"))

# Downsample (more frequent -> less frequent): use agg
ts.resample("W").sum()       # weekly sum
ts.resample("M").mean()      # monthly mean
ts.resample("QE").max()      # quarterly max

# Upsample (less -> more frequent): need fill
ts.resample("H").asfreq()            # hourly, NaN for gaps
ts.resample("H").ffill()             # forward fill
ts.resample("H").interpolate()       # linear interp

# Custom aggregation
ts.resample("W").agg(["mean", "std", "count"])

# Resample with multiple columns
df.resample("M").agg({"sales": "sum", "visits": "mean"})

# Use 'label' and 'closed' to control bin edges
ts.resample("M", label="right", closed="right").sum()

Shifting & Lags

shift, diff, and pct_change are the foundation of time-series feature engineering. shift(1) creates a lag (yesterday's value aligned to today's row) — perfect for autoregressive features. shift(-1) peeks at the future (use to build next-step targets). diff and pct_change measure changes. Always sort by time before using these, and watch for NaN at the boundaries.

pandas
# Shift values forward/backward (for time-series features)
ts.shift(1)          # move values down by 1 (yesterday's value)
ts.shift(-1)         # move up by 1 (tomorrow's value)

# Difference (today - yesterday)
ts.diff()            # first difference
ts.diff(7)           # week-over-week difference

# Percentage change
ts.pct_change()
ts.pct_change(periods=7)  # 7-day pct change

# Lag features for ML
df["lag_1"]  = df["sales"].shift(1)
df["lag_7"]  = df["sales"].shift(7)
df["roll_7"] = df["sales"].rolling(7).mean()
df["target_next"] = df["sales"].shift(-1)  # next-day target

# Shift by a time frequency
ts.shift(1, freq="D")   # shift the INDEX by 1 day

Rolling & Expanding Windows

rolling computes a statistic over a sliding window — moving averages, volatility, etc. min_periods lets partial windows compute (otherwise the first N-1 are NaN). ewm gives exponentially weighted moving averages (recent points weighted more — common in finance). expanding grows from the start (cumulative stats). Time-based windows ('7D') handle irregular sampling correctly, unlike row-count windows.

pandas
s = pd.Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], dtype=float)

# Rolling window (fixed-size sliding window)
s.rolling(3).mean()        # 3-period moving average
s.rolling(7).std()
s.rolling(3, min_periods=1).mean()  # allow partial windows

# Custom aggregation
s.rolling(3).apply(lambda x: x.max() - x.min())

# Exponentially weighted (decaying weights)
s.ewm(span=7).mean()       # EWMA — recent data weighted more
s.ewm(alpha=0.3).mean()

# Expanding window (grows from start)
s.expanding().mean()       # cumulative mean
s.expanding().max()        # running max

# Window with time-based offset (needs DatetimeIndex)
ts.rolling("7D").mean()    # 7-day window, not 7 rows
12

Categorical Data

Creating Categoricals

category dtype stores strings as integer codes plus a lookup table — huge memory savings for low-cardinality columns (a city column with 5 unique values and a million rows). Ordered categoricals support comparisons (size > 'M') and sort by category order, not alphabetically. Use category for columns with a known, small set of values.

pandas
# Convert to category dtype
df["city"] = df["city"].astype("category")

# Create with explicit categories
s = pd.Categorical(["a", "b", "a", "c"],
                    categories=["a", "b", "c", "d"])

# Ordered categorical (for sorting & comparisons)
size = pd.Categorical(["M", "L", "S", "XL"],
                       categories=["S", "M", "L", "XL"],
                       ordered=True)
size > "M"  # works because it's ordered

# Memory savings
df["city"].memory_usage(deep=True)              # object
df["city"].astype("category").memory_usage(deep=True)  # much less

The .cat Accessor

The .cat accessor manipulates categorical metadata. remove_unused_categories trims the category list to actual values — important after filtering, when many categories become empty. rename_categories changes labels without changing the underlying codes. reorder_categories changes sort order. These all return new Series; reassign to apply.

pandas
s = pd.Series(["a", "b", "a", "c"], dtype="category")

# Inspect categories
s.cat.categories        # Index(['a', 'b', 'c'])
s.cat.codes             # integer codes
s.value_counts()

# Add / remove categories
s = s.cat.add_categories(["d"])
s = s.cat.remove_categories(["d"])     # values in 'd' become NaN
s = s.cat.remove_unused_categories()

# Rename categories
s = s.cat.rename_categories({"a": "alpha", "b": "beta"})

# Reorder categories
s = s.cat.reorder_categories(["c", "b", "a"])

# Set / unset ordering
s = s.cat.as_ordered()
s = s.cat.as_unordered()

Categorical Operations

Categorical operations are generally faster than object (string) operations for groupby and value_counts. When merging two categorical columns, align their category sets first with set_categories — mismatched categories cause slow merges and silent NaNs. Convert back to string/object with astype(str) when category overhead isn't worth it (e.g. for free-text columns).

pandas
# Operations preserve categorical dtype where possible
s = pd.Series(["low", "high", "med", "low"], dtype="category")
s.value_counts()

# Comparison on ordered categoricals
size = pd.Series(["M", "L", "S"], dtype="category")
size = size.cat.as_ordered().cat.reorder_categories(["S","M","L","XL"])
size > "M"   # boolean Series

# Groupby works as expected
df.groupby("city")["score"].mean()

# Converting back
df["city"] = df["city"].astype(str)         # to string
df["city"] = df["city"].astype("object")    # to object

# Beware: merges between categoricals can be slow if categories differ
# Align categories before merging:
cats = sorted(set(df1["city"].cat.categories) |
              set(df2["city"].cat.categories))
df1["city"] = df1["city"].cat.set_categories(cats)
df2["city"] = df2["city"].cat.set_categories(cats)

Memory Optimization

Memory optimization matters when loading large data: convert low-cardinality strings to category (10-100x savings), downcast numerics (int64->int8 for small-range integers, float64->float32). The 'string' dtype (pandas 1.0+) is more memory-efficient than object and handles NaN better than object. A rule of thumb: if nunique/len < 0.5, category is worth it.

pandas
# Check current memory
df.info(memory_usage="deep")

# Convert object columns to category when cardinality is low
for col in df.select_dtypes(include="object"):
    if df[col].nunique() / len(df) < 0.5:  # <50% unique
        df[col] = df[col].astype("category")

# Downcast numeric columns
df["age"] = pd.to_numeric(df["age"], downcast="integer")
df["score"] = pd.to_numeric(df["score"], downcast="float")

# Use string dtype (pandas 1.0+) instead of object
df["name"] = df["name"].astype("string")

# Compare memory before/after
before = df.memory_usage(deep=True).sum()
# ... optimize ...
after = df.memory_usage(deep=True).sum()
print(f"Reduced from {before/1e6:.1f}MB to {after/1e6:.1f}MB")

Dummy Variables (One-Hot)

get_dummies one-hot encodes categorical columns into 0/1 indicator columns — essential before linear models that can't handle categoricals. drop_first avoids the dummy-variable trap (multicollinearity) for linear regression. For ML pipelines, prefer sklearn's OneHotEncoder (handles unseen categories at test time via handle_unknown='ignore') over get_dummies.

pandas
# One-hot encode categoricals
pd.get_dummies(df["city"])
pd.get_dummies(df, columns=["city", "pass"])  # encode multiple

# Drop first to avoid multicollinearity (for linear models)
pd.get_dummies(df["city"], drop_first=True)

# With a prefix
pd.get_dummies(df["city"], prefix="city")

# From categorical with all categories
pd.get_dummies(df["city"].astype("category"))

# Alternatively, sklearn's OneHotEncoder (for ML pipelines)
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
X_ohe = ohe.fit_transform(df[["city"]])
13

Performance & Optimization

Vectorization (avoid iterrows)

iterrows is the #1 pandas performance sin — it's 100-1000x slower than vectorized ops. Always prefer vectorized column operations (df['a'] + df['b']), .str for strings, and np.where for if-else. If you truly must loop, itertuples is much faster than iterrows (namedtuples vs Series). For complex logic, consider numba or cython, or extract to numpy with .to_numpy().

pandas
# BAD: iterrows is extremely slow
for idx, row in df.iterrows():
    df.loc[idx, "x"] = row["a"] + row["b"]

# GOOD: vectorized operations
df["x"] = df["a"] + df["b"]

# GOOD: .str accessor for strings
df["name"] = df["name"].str.upper()

# GOOD: np.where for conditionals
df["cat"] = np.where(df["a"] > 0, "pos", "neg")

# If you must iterate (rare), use itertuples (faster than iterrows)
for row in df.itertuples():
    process(row.a, row.b)

# Or .to_numpy() / .values for raw array loops
for row in df[["a", "b"]].to_numpy():
    ...

# Benchmark
%timeit df["a"] + df["b"]
%timeit df.apply(lambda r: r["a"] + r["b"], axis=1)

eval & query

eval and query parse string expressions and evaluate them with numexpr, which can be 2-4x faster than Python arithmetic on large DataFrames because it avoids creating intermediate arrays and uses C-level evaluation. The benefit shrinks on small data (parsing overhead). Useful for complex expressions on big frames — but always benchmark, as it's not always faster.

pandas
# eval: evaluate string expressions (uses numexpr, faster on big data)
df.eval("total = a + b + c", inplace=True)
df.eval("ratio = a / (b + c)")
df.eval("c = a > b")

# Multiple operations in one eval (efficient)
df.eval("""
total = a + b
ratio = a / b
flag = a > 10
""", inplace=True)

# query: boolean filter via string (same engine)
df.query("a > 0 and b < 10")

# Local variables with @
threshold = 5
df.query("a > @threshold")

# eval is faster than Python arithmetic on large frames
# because it avoids intermediate arrays

Efficient dtypes

Choosing the right dtype is the easiest big win for memory: downcast integers (int64->int8 saves 8x), use category for low-cardinality strings (10-100x), use 'string' instead of object. Nullable Int64 (capital I) handles integer columns with NaN — regular int64 can't have NaN, so such columns default to float64. Sparse dtype helps with mostly-zero data.

pandas
# Downcast numerics
df["age"] = pd.to_numeric(df["age"], downcast="unsigned")  # 0-255
df["score"] = pd.to_numeric(df["score"], downcast="float")

# Use category for low-cardinality strings
df["city"] = df["city"].astype("category")

# Use 'string' dtype instead of object
df["name"] = df["name"].astype("string")

# Use Nullable integers (Int64 with capital I) for ints with NaN
df["count"] = df["count"].astype("Int64")  # supports NaN

# Sparse for mostly-zero data
s = pd.Series([0, 0, 0, 1, 0, 0]).astype("Sparse[int]")

# Compare memory
df["city"].astype("object").memory_usage(deep=True)
df["city"].astype("category").memory_usage(deep=True)

Chunked Processing

read_csv(chunksize=...) returns an iterator — each chunk is a DataFrame of that many rows, letting you process files larger than memory. Aggregate per chunk, then combine the partial results. For SQL, chunksize uses server-side cursors. This is the simplest way to handle gigabyte-scale data without Spark; for true big data, consider Dask or Polars.

pandas
# Process a large CSV in chunks (low memory)
chunk_iter = pd.read_csv("huge.csv", chunksize=100_000)

results = []
for chunk in chunk_iter:
    # process each chunk (filter, aggregate, etc.)
    result = chunk.groupby("city")["sales"].sum()
    results.append(result)

# Combine chunk results
total = pd.concat(results).groupby(level=0).sum()

# Or use the iterator directly
for i, chunk in enumerate(pd.read_csv("huge.csv", chunksize=50000)):
    chunk.to_parquet(f"part_{i:04d}.parquet")

# For SQL, use server-side cursors
chunks = pd.read_sql("SELECT * FROM big_table", engine,
                     chunksize=100000)

Profiling & Benchmarking

Always benchmark before optimizing — %timeit quickly compares approaches. ydata-profiling (formerly pandas-profiling) generates a full HTML report (stats, correlations, missingness) — invaluable for EDA. Watch for SettingWithCopyWarning: it signals that your assignment may not persist, a common source of silent bugs. Set mode.chained_assignment='raise' to catch them as errors during development.

pandas
# Time an operation
%timeit df["a"] + df["b"]

# Line profiling (install line_profiler)
# %load_ext line_profiler
# %lprun -f my_function my_function(df)

# Memory profiling (install memory_profiler)
# %load_ext memory_profiler
# %memit df.groupby("city").sum()

# pandas profiling report (install ydata-profiling)
# from ydata_profiling import ProfileReport
# ProfileReport(df).to_file("report.html")

# Quick stats
df.info(memory_usage="deep")
df.describe(include="all")

# Find slow ops
import pandas as pd
pd.set_option("mode.chained_assignment", "warn")  # catch copy bugs
14

Databases & SQL

Reading from SQL

SQLAlchemy engines handle connection pooling and dialect differences — use them instead of raw DBAPI connections. Always use parameterized queries (params=) for user-supplied values to prevent SQL injection. chunksize streams large tables in batches. For complex queries, write them in SQL (the database is optimized for them) and let pandas handle the result.

pandas
from sqlalchemy import create_engine
import pandas as pd

# Create an engine (connection pool)
engine = create_engine("sqlite:///my.db")
engine = create_engine("postgresql://user:pass@host:5432/db")
engine = create_engine("mysql+pymysql://user:pass@host/db")

# Read a whole table
df = pd.read_sql("users", engine)

# Read a query
df = pd.read_sql("SELECT * FROM users WHERE age > 18", engine)

# Parameterized queries (safe from SQL injection)
df = pd.read_sql(
    "SELECT * FROM users WHERE city = %s",
    engine,
    params=("NYC",),
)

# Chunked reads for large tables
for chunk in pd.read_sql("SELECT * FROM big", engine, chunksize=100000):
    process(chunk)

Writing to SQL

if_exists='replace' drops and recreates the table; 'append' adds rows; 'fail' (default) raises if the table exists. method='multi' batches rows into one INSERT statement — much faster than the default one-row-per-statement. For very large writes, chunksize=10000 balances memory and round-trips. Always pass index=False unless the index is real data.

pandas
# Write a DataFrame to a SQL table
df.to_sql("users", engine, if_exists="replace", index=False)
df.to_sql("users", engine, if_exists="append", index=False)  # add rows

# Specify schema and dtype
from sqlalchemy.types import VARCHAR, INTEGER
df.to_sql("users", engine, if_exists="replace",
          dtype={"name": VARCHAR(100), "age": INTEGER()})

# Write in chunks (for large DataFrames)
df.to_sql("users", engine, if_exists="append",
          chunksize=10000, method="multi")

# method='multi' uses a single INSERT with multiple rows (faster)
# For PostgreSQL, method='multi' with chunksize ~10000 is optimal

# Don't write the index unless it's meaningful data
# index=False avoids a spurious 'index' column

SQL-style Operations in pandas

Most SQL operations map to pandas: SELECT is column selection, WHERE is boolean indexing, GROUP BY is groupby, JOIN is merge, UNION is concat+drop_duplicates, CASE WHEN is np.where. Knowing these mappings lets you choose the right tool: SQL for complex joins/aggregations on the server (letting the DB optimize), pandas for iterative analysis and plotting.

pandas
# SELECT cols
df[["name", "age"]]
# WHERE
df[df["age"] > 18]
# GROUP BY + aggregations
df.groupby("city")["age"].mean()
# ORDER BY
df.sort_values("age", ascending=False)
# LIMIT
df.head(10)
# DISTINCT
df["city"].unique()
df.drop_duplicates(subset=["city"])
# JOIN
pd.merge(df1, df2, on="id", how="inner")
# UNION
pd.concat([df1, df2]).drop_duplicates()
# CASE WHEN
np.where(df["age"] >= 18, "adult", "minor")
# HAVING
g = df.groupby("city").filter(lambda g: g["age"].mean() > 30)

SQL Execution with text()

SQLAlchemy 2.0 requires text() for raw SQL strings — it's a security measure that separates trusted SQL from untrusted parameters. engine.begin() opens a transaction that auto-commits on success and rolls back on exception — always use it for writes. Bind parameters with :name and a params dict to prevent injection.

pandas
from sqlalchemy import text

# Use text() for explicit SQL (recommended over raw strings)
query = text("SELECT city, COUNT(*) as n FROM users GROUP BY city")
df = pd.read_sql(query, engine)

# Bind parameters safely
query = text("SELECT * FROM users WHERE age > :min_age AND city = :city")
df = pd.read_sql(query, engine,
                 params={"min_age": 18, "city": "NYC"})

# Execute DDL or DML (non-SELECT)
with engine.begin() as conn:  # transaction
    conn.execute(text("CREATE INDEX idx_city ON users(city)"))
    conn.execute(text("DELETE FROM users WHERE id = :id"),
                 {"id": 999})

Parquet vs CSV vs SQL

Parquet is the best default for storage — columnar, compressed, and dtype-preserving (CSV loses dtypes and forces re-inference on every load). Feather is the fastest for short-lived caches (Apache Arrow format, no serialization overhead). Pickle is convenient but breaks across Python/pandas versions. Use SQL when data is shared and needs server-side querying.

pandas
# CSV: human-readable, slow, loses dtypes, large
df.to_csv("data.csv", index=False)
df = pd.read_csv("data.csv")  # re-infers dtypes (slow, error-prone)

# Parquet: binary, fast, preserves dtypes, compressed (best for storage)
df.to_parquet("data.parquet")
df = pd.read_parquet("data.parquet")  # dtypes preserved exactly

# Feather: fastest for short-lived storage (Apache Arrow)
df.to_feather("data.feather")  # requires a RangeIndex
df = pd.read_feather("data.feather")

# Pickle: Python-specific, fast, but version-fragile
df.to_pickle("data.pkl")
df = pd.read_pickle("data.pkl")

# SQL: shared, queryable, but slower for bulk reads
df.to_sql("table", engine)
df = pd.read_sql("SELECT * FROM table", engine)

# Rule of thumb:
# - CSV for human exchange
# - Parquet for storage/archival (best default)
# - Feather for fast cache between Python processes
# - SQL for shared, queryable data
15

Time Series Analysis

Setting a DatetimeIndex

Time-series work in pandas starts with a DatetimeIndex — set the date column as the index and sort it. Then you can slice with strings (df.loc['2024-06'] for all of June) and use resample/rolling/shift. Partial-string indexing is incredibly convenient. Always sort the index after setting it; many time-series methods require sorted indexes.

pandas
# Ensure the date column is datetime, then set as index
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date").sort_index()

# Now you can slice by date strings
df.loc["2024"]                 # all of 2024
df.loc["2024-06"]              # June 2024
df.loc["2024-06-01":"2024-06-15"]  # date range (inclusive)

# Partial string indexing on Series
s = df["value"]
s["2024-06-15"]

# Filter with date strings
df[df.index >= "2024-01-01"]

# A DatetimeIndex unlocks resample, rolling, shift, etc.

Resampling in Practice

resample is the time-series groupby — 'MS' (month start), 'ME' (month end), 'QE' (quarter end), 'W' (weekly). Combine with groupby for per-category resampling (e.g. weekly sales per city). asfreq adds rows for missing time periods (filling with NaN), which is useful to expose gaps in irregular data. The last() aggregation takes the final value in each period.

pandas
# Daily -> monthly revenue
daily.resample("MS").sum()

# Common frequencies:
# D=day, B=business day, W=weekly, MS=month start, ME=month end,
# QS=quarter start, YE=year end, H=hour, T/min=minute, S=second

# Multiple aggregations
daily.resample("ME").agg({"sales": "sum", "users": "mean"})

# Resample per group
df.groupby("city").resample("W")["sales"].sum()

# Handle missing periods (fill gaps in the timeline)
daily.asfreq("D")           # add NaN rows for missing days
daily.asfreq("D").ffill()   # fill them

# Quarter-end reporting
daily.resample("QE").last()  # last value of each quarter

Time Zones

tz_localize assigns a timezone to naive timestamps (without changing the values); tz_convert converts to another timezone (changing the displayed values). The two are easy to confuse. Always localize first, then convert. DST transitions create ambiguous/missing times — handle them with ambiguous='infer' (for sorted data) or 'NaT'.

pandas
# Localize naive timestamps (assign a timezone)
s = pd.Series(pd.date_range("2024-01-01", periods=3, freq="H"))
s_localized = s.dt.tz_localize("UTC")
s_localized = s.dt.tz_localize("America/New_York")

# Convert between timezones
s_utc = s.dt.tz_localize("UTC")
s_est = s_utc.dt.tz_convert("US/Eastern")
s_hk  = s_utc.dt.tz_convert("Asia/Hong_Kong")

# On a DatetimeIndex
idx = pd.date_range("2024-01-01", periods=3, freq="H", tz="UTC")
idx = idx.tz_convert("Asia/Shanghai")

# Remove timezone info
s_naive = s_utc.dt.tz_localize(None)

# Ambiguous times (DST fall-back): use 'infer' or 'NaT'
s.dt.tz_localize("US/Eastern", ambiguous="infer")

Window Functions

Time-based rolling windows ('7D') handle irregularly-sampled data correctly, unlike row-count windows (rolling(7)). ewm (exponentially weighted) weights recent observations more — common in finance for smoothing. groupby + rolling computes per-group windows (e.g. 7-day moving average per stock). reset_index cleans up the MultiIndex that groupby+rolling produces.

pandas
# Rolling window with time offset
ts.rolling("7D").mean()      # 7-day window (handles irregular sampling)

# Rolling with custom function
ts.rolling("30D").apply(lambda x: np.quantile(x, 0.95))

# Expanding window (cumulative)
ts.expanding(min_periods=10).mean()

# Exponentially weighted
ts.ewm(span=20).mean()       # like a 20-period EWMA
ts.ewm(halflife=10).mean()

# Window per group
df.groupby("city")["sales"].rolling("7D").mean().reset_index(level=0, drop=True)

# Compare rolling vs ewm for smoothing
# ewm weights recent points more — better for fast-changing series

Seasonality & Decomposition

seasonal_decompose splits a time series into trend, seasonal, and residual components — useful for understanding structure and deseasonalizing. period is the cycle length (12 for monthly data with yearly seasonality). Groupby on index.month or index.dayofweek reveals seasonal patterns. The autocorrelation_plot shows how correlated a series is with its own lags.

pandas
# Seasonal decomposition (needs statsmodels)
from statsmodels.tsa.seasonal import seasonal_decompose

# Additive model: y = trend + seasonal + residual
result = seasonal_decompose(ts, model="additive", period=12)
result.plot()
result.trend, result.seasonal, result.resid

# Multiplicative model: y = trend * seasonal * residual
result = seasonal_decompose(ts, model="multiplicative", period=12)

# Seasonal plots
ts.groupby(ts.index.month).mean()   # average by month
ts.groupby(ts.index.dayofweek).mean()  # by weekday

# Lag plot (autocorrelation check)
pd.plotting.lag_plot(ts, lag=1)
pd.plotting.autocorrelation_plot(ts)
16

Visualization

Basic Plots

Pandas plotting is built on matplotlib — the .plot accessor gives quick charts for EDA. plot() defaults to line (good for time series), plot.bar() for categorical counts, plot.hist() for distributions, plot.scatter() for relationships. For publication-quality charts, switch to seaborn or plotly, but for quick inspection during analysis, pandas plots are unbeatable.

pandas
import matplotlib.pyplot as plt

# Line plot (default for time series)
df["score"].plot()
df.plot(x="date", y="score")

# Bar chart
df["city"].value_counts().plot.bar()
df["city"].value_counts().plot.barh()  # horizontal

# Histogram
df["age"].plot.hist(bins=20)
df[["age", "score"]].plot.hist(alpha=0.5, bins=20)

# Scatter
df.plot.scatter(x="age", y="score", c="pass", colormap="viridis")

# Box plot (per group)
df.boxplot(column="score", by="city")

# Area, density, pie
df["city"].value_counts().plot.pie(autopct="%1.1f%%")
df["score"].plot.kde()  # kernel density estimate

plt.tight_layout(); plt.show()

Plotting with Seaborn

Seaborn shines for statistical visualizations: violin plots, pair plots (scatter matrix), heatmaps, regression lines with confidence intervals. It handles the 'group by color' (hue) elegantly, which is verbose in matplotlib. pairplot is the fastest way to scan all pairwise relationships in a dataset. Always pass the DataFrame and use column names — seaborn's API is designed for tidy data.

pandas
import seaborn as sns
import matplotlib.pyplot as plt

# Statistical plots that pandas can't easily do
sns.histplot(df["score", kde=True])
sns.boxplot(data=df, x="city", y="score")
sns.violinplot(data=df, x="city", y="score")

# Relationships
sns.scatterplot(data=df, x="age", y="score", hue="city")
sns.pairplot(df[["age", "score", "pass"]], hue="pass")

# Correlation heatmap
sns.heatmap(df.corr(numeric_only=True), annot=True, cmap="coolwarm")

# Regression plot
sns.regplot(data=df, x="age", y="score")

# Facet grid (small multiples)
g = sns.FacetGrid(df, col="city", hue="pass")
g.map(sns.histplot, "score")

plt.tight_layout(); plt.show()

Interactive Plots with Plotly

Plotly Express produces interactive charts (hover tooltips, zoom, pan) — great for dashboards and sharing insights with non-technical stakeholders. The API mirrors seaborn (pass a DataFrame, map columns to aesthetics). write_html saves a self-contained interactive HTML file. For pure performance with millions of points, consider datashader; for polish, Plotly is excellent.

pandas
import plotly.express as px

# Interactive scatter (hover, zoom, pan)
fig = px.scatter(df, x="age", y="score", color="city",
                 hover_data=["name"], title="Score vs Age")
fig.show()

# Line chart
fig = px.line(df, x="date", y="score", color="city")

# Bar
fig = px.bar(df, x="city", y="score", color="pass", barmode="group")

# Histogram
fig = px.histogram(df, x="score", color="city", marginal="box")

# Faceted
fig = px.scatter(df, x="age", y="score", facet_col="city")

# 3D
fig = px.scatter_3d(df, x="age", y="score", z="hours", color="city")

# Save as HTML (interactive, no JS needed to view)
fig.write_html("plot.html")

Subplots & Layouts

plt.subplots creates a grid of axes; pass each to ax= when plotting. sharex/sharey aligns axes across subplots (useful for comparing scales). secondary_y plots a second series on a different y-axis (e.g. price and volume). tight_layout prevents label overlap. For complex layouts, gridspec offers finer control over subplot sizing.

pandas
import matplotlib.pyplot as plt

# pandas plot on specific axes
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
df["age"].plot.hist(ax=axes[0, 0], title="Age")
df["score"].plot.hist(ax=axes[0, 1], title="Score")
df["city"].value_counts().plot.bar(ax=axes[1, 0], title="City")
df.plot.scatter(x="age", y="score", ax=axes[1, 1], title="Age vs Score")
plt.tight_layout(); plt.show()

# Shared x-axis
fig, (ax1, ax2) = plt.subplots(2, 1, sharex=True, figsize=(10, 6))
df["score"].plot(ax=ax1)
df["age"].plot(ax=ax2)

# Secondary y-axis
ax = df["score"].plot()
df["age"].plot(ax=ax, secondary_y=True)
ax.set_ylabel("Score"); ax.right_ax.set_ylabel("Age")

Styling DataFrames

Styler objects customize how DataFrames render in Jupyter and HTML — gradients, bars-in-cells, and conditional formatting make tables readable at a glance. format controls number display (decimals, percentages). Styling is computed at display time (doesn't change the data) and can be exported to HTML for reports. applymap applies a function per cell; apply works row/column-wise.

pandas
# Style a DataFrame for HTML/Jupyter display
styled = df.style.highlight_max(color="lightgreen")
styled = df.style.highlight_min(color="lightcoral")
styled = df.style.background_gradient(cmap="Blues", subset=["score"])

# Bar charts inside cells
df.style.bar(subset=["score"], color="#5fba7d")

# Format numbers
df.style.format({"score": "{:.2f}", "age": "{:.0f}"})
df.style.format({"score": "{:.2%}"})  # as percentage

# Conditional formatting
def color_neg_red(val):
    color = "red" if val < 0 else "black"
    return f"color: {color}"
df.style.applymap(color_neg_red, subset=["change"])

# Combine styles
(df.style
   .format({"score": "{:.2f}"})
   .background_gradient(cmap="RdYlGn", subset=["score"])
   .set_caption("Student Scores"))

# Export to HTML
styled.to_html("table.html")
17

Export & Formatting

Formatting Display

Display options control how pandas prints in the console/Jupyter — increase max_rows/max_columns to see more, set float_format for consistent decimals. option_context is a context manager that temporarily changes settings (restores them after), perfect for one-off inspection without globally changing your environment. Put commonly-used settings in a startup script.

pandas
# Global display options
pd.set_option("display.max_rows", 100)
pd.set_option("display.max_columns", 50)
pd.set_option("display.width", 1000)
pd.set_option("display.max_colwidth", 100)
pd.set_option("display.float_format", "{:.2f}".format)

# Reset to defaults
pd.reset_option("display.max_rows")

# Float formatting only for one operation
with pd.option_context("display.float_format", "{:.2f}".format):
    print(df)

# Temporarily show more rows
with pd.option_context("display.max_rows", None):
    print(df.head(100))

Export to Multiple Formats

to_markdown produces GitHub-ready tables for READMEs and docs. to_html embeds in web pages; to_latex for academic papers. to_dict(orient='records') converts to a list of dicts (JSON-like) for APIs. Each format has options — explore the docs. For Excel, the engine='openpyxl' supports formatting formulas, conditional formats, and charts.

pandas
# CSV
df.to_csv("out.csv", index=False)
df.to_csv("out.tsv", sep="\t", index=False)

# Excel with formatting (needs openpyxl)
with pd.ExcelWriter("out.xlsx", engine="openpyxl") as w:
    df.to_excel(w, sheet_name="data", index=False)
    summary.to_excel(w, sheet_name="summary")

# Markdown (great for docs/reports)
print(df.to_markdown(index=False))
df.to_markdown("table.md", index=False)

# HTML
df.to_html("table.html", index=False, classes="table")

# LaTeX
print(df.to_latex(index=False, caption="Results"))

# JSON
df.to_json("out.json", orient="records", indent=2, date_format="iso")

# Dict / records
df.to_dict(orient="records")  # list of dicts
df.to_dict(orient="list")     # dict of lists

Clipboard & String

to_clipboard(excel=True) copies a tab-separated table that pastes directly into Excel/Google Sheets — incredibly handy for moving data between Python and spreadsheets. read_clipboard is the reverse: copy a table from a webpage or spreadsheet, then read it into a DataFrame without saving a file. to_string gives a plain-text representation for logging.

pandas
# Copy a DataFrame to the clipboard (paste into Excel/Sheets)
df.to_clipboard(index=False, excel=True)

# Read from clipboard (paste a table from a spreadsheet/webpage)
df = pd.read_clipboard()

# Convert to string with formatting
print(df.to_string(index=False))
print(df.to_string(max_rows=10))

# Pretty-print in Jupyter
from IPython.display import display
display(df.head(10))

# Render a Series as a string
print(s.to_string())

# Quick peek at value (useful in debugging)
df.head().to_dict("records")

Excel Formatting

openpyxl lets you style Excel output programmatically — bold headers, column widths, freeze panes, conditional formatting, formulas. Access the workbook via writer.book and sheets via writer.sheets after writing. Auto-sizing columns requires manual width calculation (Excel doesn't do it automatically on file write). Freeze panes keep headers visible while scrolling.

pandas
from openpyxl.styles import Font, PatternFill
from openpyxl.utils import get_column_letter

# Write with conditional formatting
with pd.ExcelWriter("report.xlsx", engine="openpyxl") as writer:
    df.to_excel(writer, sheet_name="Data", index=False)

    # Access the workbook to style
    wb = writer.book
    ws = writer.sheets["Data"]

    # Bold header
    for cell in ws[1]:
        cell.font = Font(bold=True, color="FFFFFF")
        cell.fill = PatternFill("solid", fgColor="4472C4")

    # Auto-size columns
    for col in ws.columns:
        max_len = max(len(str(c.value)) for c in col if c.value is not None)
        ws.column_dimensions[get_column_letter(col[0].column)].width = max_len + 2

    # Freeze header row
    ws.freeze_panes = "A2"

Reproducible Notebooks

Standardizing notebook headers (imports, options, seed, version check) makes analyses reproducible and saves debugging time. pd.show_versions() reports pandas/numpy/python versions — paste it in bug reports. A check_df helper that prints shape, dtypes, head, and missingness standardizes the first look at any new dataset. Suppress only specific warning categories, never all.

pandas
# At the top of every analysis notebook
import pandas as pd
import numpy as np
pd.set_option("display.max_columns", None)
pd.set_option("display.float_format", "{:.4f}".format)

# Set a seed for reproducibility
np.random.seed(42)

# Show versions for reproducibility
pd.show_versions()

# Common imports grouped
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid")

# Suppress chained-assignment warnings cleanly
import warnings
warnings.filterwarnings("ignore", category=FutureWarning)

# Validate data on load
def check_df(df, name="df"):
    print(f"=== {name} ===")
    print(f"Shape: {df.shape}")
    print(df.dtypes)
    print(df.head())
    print(df.isnull().sum())
check_df(df)
18

Common Gotchas

SettingWithCopyWarning

SettingWithCopyWarning is pandas telling you your assignment might not persist — chained indexing (df[mask]['col']=x) may modify a temporary copy. The fix is always .loc[row_mask, col_name] = value for conditional assignment. When subsetting then modifying, use .copy() to be explicit. In development, set the warning to 'raise' to catch these bugs as errors.

pandas
# BAD: chained indexing — may not modify df
df[df["age"] > 30]["city"] = "NYC"   # SettingWithCopyWarning

# GOOD: single .loc assignment
df.loc[df["age"] > 30, "city"] = "NYC"

# When subsetting, be explicit about copy vs view
subset = df[df["age"] > 30].copy()   # explicit copy
subset["city"] = "NYC"               # safe — modifies subset only

# Detect the warning
pd.set_option("mode.chained_assignment", "warn")   # default
pd.set_option("mode.chained_assignment", "raise")  # error (dev mode)
pd.set_option("mode.chained_assignment", None)     # suppress

# Rule: if you're going to modify, use .loc or .copy() first

Index Alignment Surprises

Index alignment is pandas' superpower and its biggest gotcha. When you assign a Series to a column, pandas matches by index, not position — so if your Series has a different index, you get NaN or wrong values. Use .values to assign by position, or reset_index to align indexes first. This bug is silent (no warning) and is one of the most common pandas mistakes.

pandas
# Adding Series aligns on index — NaN where they don't match
s1 = pd.Series([1, 2, 3], index=["a", "b", "c"])
s2 = pd.Series([10, 20, 30], index=["b", "c", "d"])
s1 + s2
# a     NaN
# b    12.0
# c    33.0
# d     NaN

# When assigning a Series to a column, alignment happens by index
df["new"] = some_series   # rows matched by INDEX, not position!

# To force positional alignment, use .values
df["new"] = some_series.values   # assign by position

# After sorting/resetting, indexes may not match
df1 = df.sort_values("age")
df1["rank"] = range(len(df1))   # WRONG if index isn't 0..n-1
df1["rank"] = np.arange(len(df1))  # also WRONG — aligns on index
df1["rank"] = pd.Series(range(len(df1)), index=df1.index)  # correct
# Or simply:
df1 = df1.reset_index(drop=True)
df1["rank"] = range(len(df1))

inplace vs Reassignment

The pandas community is moving away from inplace=True toward functional reassignment — it enables method chaining (readable pipelines) and is more predictable. inplace rarely saves memory (pandas often copies anyway). The chaining style (df.method().method().method()) reads top-to-bottom and is the modern idiom. Use inplace only when you've measured a real memory benefit.

pandas
# Two equivalent styles:
df = df.sort_values("age")        # reassign (returns new df)
df.sort_values("age", inplace=True)  # modify in place

# Most pandas methods return a new object (functional style).
# inplace=True modifies the object — but:
# 1. It's being deprecated in some methods
# 2. It can prevent method chaining
# 3. It sometimes still makes a copy under the hood

# Prefer functional style for chaining:
(df
   .sort_values("age")
   .reset_index(drop=True)
   .rename(columns={"old": "new"}))

# inplace is fine for memory savings on huge DataFrames,
# but rarely matters otherwise.

Mutable Default Arguments

Growing a DataFrame row-by-row (df.append or pd.concat in a loop) is O(n²) — each append copies the whole frame. Always collect rows in a list, then build the DataFrame once. The same applies to Python's mutable default arguments (lists, dicts) — they're shared across calls, a classic Python bug. Use None as a sentinel and initialize inside the function.

pandas
# BAD: default mutable args are shared across calls
def add_row(df, new_data=[]):   # NEVER use mutable defaults
    new_data.append(1)
    return df

# GOOD: use None
def add_row(df, new_data=None):
    if new_data is None:
        new_data = []
    ...

# Building a DataFrame row by row (SLOW — anti-pattern)
df = pd.DataFrame()
for item in items:
    df = pd.append(df, item)  # deprecation + O(n^2)

# GOOD: build a list of records, then construct once
records = []
for item in items:
    records.append({"name": item.name, "value": item.value})
df = pd.DataFrame(records)

# Or use from_records
df = pd.DataFrame.from_records(items, columns=["name", "value"])

NaN Propagation

NaN never equals itself (NaN == NaN is False) — use isna() to detect missing values, never ==. By default aggregations skip NaN (sum, mean), but pass skipna=False to propagate it. Integer columns with any NaN get upcast to float64 (since numpy ints can't hold NaN); use the nullable 'Int64' dtype (capital I) to keep integers with NaN.

pandas
# NaN propagates through most operations
np.nan + 1          # nan
np.nan > 0          # False
pd.Series([1, 2, np.nan]).sum()    # 3.0 (skips NaN by default)
pd.Series([1, 2, np.nan]).mean()   # 1.5

# Some operations DON'T skip NaN
pd.Series([1, 2, np.nan]).sum(skipna=False)  # nan

# Comparisons with NaN are always False
pd.Series([1, np.nan]) == pd.Series([1, np.nan])
# [True, False]  — NaN != NaN!

# Check for NaN correctly
s.isna()            # True for NaN
s.isnull()          # alias
s == np.nan         # WRONG — always False!
s.fillna(0) == 0    # check after filling

# Integer columns with NaN become float
s = pd.Series([1, 2, None])   # becomes float64: [1.0, 2.0, NaN]
# Use nullable Int64 to keep integer with NaN
s = pd.Series([1, 2, None], dtype="Int64")
19

Real-World Patterns

EDA Template

A standardized EDA function ensures you always check shape, dtypes, missingness, distributions, and duplicates before modeling. Catching a wrong dtype (numbers as strings), hidden missingness (sentinel values like -999), or unexpected duplicates early saves hours of downstream debugging. Run this on every new dataset as the first step.

pandas
import pandas as pd
import numpy as np

def eda(df, name="df"):
    print(f"=== {name} ===")
    print(f"Shape: {df.shape}")
    print("\n--- dtypes ---")
    print(df.dtypes)
    print("\n--- head ---")
    print(df.head())
    print("\n--- missing ---")
    print(df.isnull().sum())
    print("\n--- numeric summary ---")
    print(df.describe())
    print("\n--- categorical summary ---")
    print(df.describe(include="object"))
    print("\n--- duplicates ---")
    print(f"{df.duplicated().sum()} duplicate rows")

# Usage
eda(df)

# Per-column deep dive
for col in df.columns:
    print(f"\n### {col}")
    print(f"dtype: {df[col].dtype}")
    print(f"unique: {df[col].nunique()}")
    if df[col].dtype == "object":
        print(df[col].value_counts().head(10))
    else:
        print(df[col].describe())

Building a Cleaning Pipeline

Method chaining (the .pipe / .assign / .dropna pipeline) makes cleaning reproducible and testable — each step is a pure function, easy to inspect at any point. Rename columns to snake_case early to avoid quoting headaches. assertions at the end catch silent failures (e.g. all dates failing to parse). Wrap the pipeline in a function so it can be reused on new data batches.

pandas
def clean(df):
    """A reusable cleaning pipeline."""
    return (df
        # Standardize column names
        .rename(columns=lambda c: c.strip().lower().replace(" ", "_"))
        # Drop empty columns
        .dropna(axis=1, how="all")
        # Parse dates
        .assign(date=lambda d: pd.to_datetime(d["date"], errors="coerce"))
        # Numeric coercion
        .assign(amount=lambda d: pd.to_numeric(d["amount"], errors="coerce"))
        # Fill missing
        .fillna({"category": "unknown"})
        # Dedupe
        .drop_duplicates()
        # Reset index
        .reset_index(drop=True)
    )

df_clean = clean(df_raw)

# Verify
assert df_clean["date"].notnull().all(), "Some dates failed to parse"
assert len(df_clean) <= len(df_raw), "Cleaning should not add rows"

Pivot Table Analysis

pivot_table is the analyst's swiss-army knife — combine it with margins=True for totals and a heatmap for instant insight. The inverse (melt) takes you back to tidy long format for further processing. The 'top N per group' pattern (sort then groupby head) is extremely common in business reporting.

pandas
# Multi-dimensional analysis in one shot
pivot = pd.pivot_table(
    df,
    values="revenue",
    index=["region", "product"],
    columns=["quarter"],
    aggfunc="sum",
    fill_value=0,
    margins=True,         # add row/column totals
    margins_name="Total",
)

# Heatmap of the pivot
import seaborn as sns
sns.heatmap(pivot, annot=True, fmt=".0f", cmap="YlGnBu")

# Convert back to long
long = pivot.reset_index().melt(
    id_vars=["region", "product"],
    var_name="quarter",
    value_name="revenue",
)

# Top product per region
top = (df
    .groupby(["region", "product"])["revenue"].sum()
    .reset_index()
    .sort_values("revenue", ascending=False)
    .groupby("region").head(1))

Feature Engineering for ML

Feature engineering is where domain knowledge meets pandas: aggregations per entity (customer lifetime spend), ratios (spend per month), time-based features (tenure), and binned categoricals. Merge aggregated features back onto the main frame by entity ID. get_dummies one-hot encodes for linear models. Wrap in a function so the same transformations apply at train and inference time.

pandas
def add_features(df):
    """Add features for a churn model."""
    out = df.copy()

    # Time-based features
    out["tenure_years"] = out["tenure_months"] / 12
    out["is_new_customer"] = (out["tenure_months"] < 12).astype(int)

    # Aggregations per customer (need transaction history)
    # txns is a separate transactions DataFrame
    agg = txns.groupby("customer_id").agg(
        total_spend=("amount", "sum"),
        txn_count=("amount", "count"),
        avg_txn=("amount", "mean"),
        last_txn_date=("date", "max"),
    ).reset_index()
    out = out.merge(agg, on="customer_id", how="left")

    # Ratio features
    out["spend_per_month"] = out["total_spend"] / out["tenure_months"]

    # Binning
    out["age_group"] = pd.cut(out["age"], bins=[0,25,40,60,100],
                              labels=["young","adult","mid","senior"])

    # Encode categoricals
    out = pd.get_dummies(out, columns=["age_group", "plan"], drop_first=True)

    return out

df_ml = add_features(df)

Reading Messy CSVs

Real-world CSVs are messy: wrong encodings (try latin-1 or cp1252 when utf-8 fails), European decimal commas (decimal=','), metadata rows before the header (skiprows), and sentinel null values (na_values). usecols loads only needed columns, saving memory on wide files. Always inspect the first few lines of an unfamiliar CSV with a text editor before reading it blindly.

pandas
# Common CSV issues and fixes

# Bad encoding
df = pd.read_csv("data.csv", encoding="latin-1")  # or utf-8-sig, cp1252

# Wrong separator
df = pd.read_csv("data.tsv", sep="\t")
df = pd.read_csv("data.csv", sep=";", decimal=",")  # European format

# Skip metadata rows at the top
df = pd.read_csv("data.csv", skiprows=3)

# Multiple header rows
df = pd.read_csv("data.csv", header=[0, 1])

# Custom NA values
df = pd.read_csv("data.csv", na_values=["?", "N/A", "NULL", -999])

# Parse dates with multiple formats
df = pd.read_csv("data.csv", parse_dates=["date"], dayfirst=True)

# Only load specific columns (saves memory)
df = pd.read_csv("data.csv", usecols=["id", "date", "amount"])

# Handle quoted fields with commas/newlines
df = pd.read_csv("data.csv", quotechar='"', escapechar="\\")
20

Window Operations Deep Dive

Rolling with Custom Functions

Built-in rolling aggregations (mean, std, median, quantile) are C-optimized and fast. Custom apply functions are flexible but slower (Python callback per window). For weighted averages or regressions, apply with numpy ops inside. raw=True (default) passes numpy arrays (faster); raw=False passes Series if you need the index.

pandas
s = pd.Series(range(20), dtype=float)

# Built-in aggregations (fast, C-optimized)
s.rolling(5).mean()
s.rolling(5).std()
s.rolling(5).median()
s.rolling(5).quantile(0.75)
s.rolling(5).sum()

# Custom function (slower, but flexible)
s.rolling(5).apply(lambda x: x.max() - x.min())

# Weighted rolling average
weights = np.array([0.1, 0.2, 0.3, 0.25, 0.15])
s.rolling(5).apply(lambda x: np.dot(x, weights))

# Rolling regression (slope over window)
def slope(x):
    return np.polyfit(range(len(x)), x, 1)[0]
s.rolling(10).apply(slope)

# raw=False passes Series (with index) to the function
s.rolling(5).apply(lambda x: x.iloc[-1] / x.iloc[0], raw=False)

Time-based Windows

Time-based windows ('7D') handle irregularly-sampled data correctly — they include all points within the time range, not a fixed number of rows. This is essential for real-world time series with gaps. center=True centers the window on the current point (useful for smoothing). closed='left' excludes the current point (avoid leakage when computing features from the 'future').

pandas
# Row-count vs time-based windows
ts = pd.Series(range(100), index=pd.date_range("2024-01-01", periods=100, freq="D"))

# Row-count: exactly 7 rows (may span more than 7 days if data is irregular)
ts.rolling(7).mean()

# Time-based: all rows within the last 7 days (handles gaps)
ts.rolling("7D").mean()

# Time-based is essential for irregular time series
irregular = pd.Series([1, 2, 3],
    index=pd.to_datetime(["2024-01-01", "2024-01-05", "2024-01-15"]))
irregular.rolling("7D").sum()   # 1, 3, 3 (third is just itself, no neighbors in 7d)

# Center the window
ts.rolling("7D", center=True).mean()  # window centered on current point

# Closed controls which endpoints are included
ts.rolling("7D", closed="left").mean()  # exclude current point

Expanding & EWMA

expanding computes statistics over all data from the start to the current point — useful for cumulative benchmarks and running extremes. ewm (exponentially weighted) weights recent observations more — the standard for financial smoothing (EWMA). span is intuitive (similar to an N-period moving average); alpha is the raw smoothing factor. EWMA reacts faster than a simple moving average to recent changes.

pandas
s = pd.Series([1, 2, 3, 4, 5, 6, 7, 8], dtype=float)

# Expanding: window grows from the start
s.expanding(min_periods=2).mean()   # cumulative mean
s.expanding().max()                 # running maximum
s.expanding().std()

# Exponentially weighted moving average
s.ewm(span=5).mean()       # span=N similar to N-period moving avg
s.ewm(alpha=0.3).mean()    # alpha = smoothing factor (0-1)
s.ewm(halflife=3).mean()   # halflife: weight halves every N periods

# EWMA volatility (finance)
returns.ewm(span=20).std()  # 20-period EWMA volatility

# Relationship: span = (2/alpha) - 1
# Higher alpha/span = more responsive to recent data

Cumulative Operations

cumsum, cummax, cummin, cumprod are direct cumulative methods — faster than expanding for the common cases. Combined with groupby they compute per-group running totals (e.g. running balance per customer). The trick of cumsum on a boolean condition creates session/segment identifiers — useful for splitting time series into episodes between events.

pandas
s = pd.Series([1, 2, 3, 4, 5])

# Direct cumulative methods (faster than expanding)
s.cumsum()    # [1, 3, 6, 10, 15]
s.cummax()    # [1, 2, 3, 4, 5]
s.cummin()    # [1, 1, 1, 1, 1]
s.cumprod()   # [1, 2, 6, 24, 120]

# Cumulative within groups
df["running_total"] = df.groupby("customer")["amount"].cumsum()
df["rank_in_group"] = df.groupby("customer")["amount"].rank(ascending=False)
df["pct_of_total"] = df["amount"] / df.groupby("customer")["amount"].transform("sum")

# Cumulative count of condition
df["days_since_event"] = (df["event"] == 1).cumsum()
df["event_number"] = df.groupby(
    (df["event"] == 1).cumsum()
).cumcount()

Rolling Apply with Multiple Outputs

For multiple rolling statistics, computing each separately (window.mean(), window.std()) is clearer and often faster than a single apply returning a Series. Rolling quantiles let you build confidence bands (q10/q90) around a trend. Creating a DataFrame of rolling stats is the typical pattern before plotting or feeding into an ML model.

pandas
# Apply a function returning multiple values per window
def rolling_stats(x):
    return pd.Series({
        "mean": x.mean(),
        "std": x.std(),
        "min": x.min(),
        "max": x.max(),
    })

stats = s.rolling(10).apply(rolling_stats, raw=False)
# This creates a DataFrame with one row per window, columns = the Series index

# Alternative: compute each separately (often clearer)
window = s.rolling(10)
result = pd.DataFrame({
    "mean": window.mean(),
    "std":  window.std(),
    "min":  window.min(),
    "max":  window.max(),
})

# Rolling quantiles
q = pd.DataFrame({
    "q10": s.rolling(20).quantile(0.1),
    "q50": s.rolling(20).quantile(0.5),
    "q90": s.rolling(20).quantile(0.9),
})

Was this helpful?

Learning path

Learn from scratch

Learn this language from the ground up with structured lessons.