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.
# 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.
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.
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.
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.
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)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.
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.
# 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.
# 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.
# 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.
# 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))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.
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 memoryIndexing & 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.
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.
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).
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.
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", ""])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.
# 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.
# .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 # SettingWithCopyWarningBoolean 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.
# 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.
# 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 arraysSampling & 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.
# 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)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.
# 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.
# 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.
# 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.TType 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.
# 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.
# 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)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'].
# 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'].