Part 7 · Scientific Computing & Data Science · Module 51

pandas: Series & DataFrames

Most real data arrives as tables: exports, logs, spreadsheets, survey results. pandas gives you a labelled, typed table in memory and a vocabulary of fast operations for loading, cleaning, combining, summarizing, and reshaping it. This module teaches that vocabulary well enough that you can take a messy CSV and turn it into trustworthy numbers.

⏱ ~3.5 hoursPrerequisites: M48 NumPy, M50 SciPy (helpful)

The data model: Index, Series, DataFrame

In M48 you met the NumPy ndarray: a block of same-typed values with a shape. It is fast, but its rows and columns are only numbered. Real tables have names: a column called price, a row for order A-1007, a date. pandas adds labels on top of arrays.

columns (an Index of column labels) productqtydate A-1001A-1002A-1003 muglampmug 215 2025-03-012025-03-012025-03-02 dtype: strdtype: int64datetime64 index (row labels) df["qty"] is a Series: values + the same index + a name each column is stored as its own typed array
A DataFrame is a dictionary-like collection of Series that share one row index.

Index alignment: the rule that surprises everyone once

When you combine two Series, pandas matches values by label, not by position. Labels present in only one operand produce a missing value (NaN, "not a number"). This is the single most important behavior to internalize: it is why df["total"] = df["qty"] * df["price"] is safe even after you sort one of them, and why adding two Series with different indexes silently produces holes.

Why that output?

The result index is the sorted union of both indexes: w, x, y, z. Only y and z appear in both, so w and x become NaN. Because NaN is a float, the whole result becomes float64. If you want a default instead of a hole, use the method form: a.add(b, fill_value=0).

Remember: an Index holds labels, a Series is labelled values with one dtype, and a DataFrame is columns of Series sharing a row index. Operations align on labels.

dtypes and the pandas 3.0 defaults

Every column has a dtype that decides how values are stored and which operations are valid. The common ones:

dtypeHoldsMissing valueNotes
int64whole numbersnone (cannot hold NaN)a missing value forces float64
Int64 (capital I)nullable whole numberspd.NAopt in with dtype="Int64"
float64real numbersNaNthe NumPy default
bool / booleanTrue/Falsenone / pd.NAmasks are bool
strtextNaNthe default for text in pandas 3.0
categorya small set of repeated labelsNaNstores integer codes plus a lookup table
datetime64[ns], datetime64[us]timestampsNaToptionally time-zone aware
objectarbitrary Python objectsNone/NaNslow; avoid when a real dtype exists

The default string dtype

Before pandas 3.0, text columns were stored with the generic object dtype: an array of pointers to arbitrary Python objects, so pandas could not even promise the column contained strings. pandas 3.0 infers a dedicated string dtype for text by default. It prints as str (its full name is StringDtype(na_value=nan)), it uses NaN as its missing-value marker so it behaves like the other default dtypes, and it is backed by PyArrow when PyArrow is installed (falling back to a Python-object implementation when it is not). In practice this means text columns use less memory, string methods are faster, and assigning a non-string into a str column raises an error instead of silently mixing types.

Copy-on-Write

In pandas 3.0, Copy-on-Write (CoW) is always on. The rule is simple: any object you get from indexing or from a method behaves as an independent copy. pandas still shares memory behind the scenes for speed, and only copies at the moment one side is modified. Two consequences:

This replaced the old, confusing SettingWithCopyWarning, where the answer to "did my change stick?" depended on whether pandas happened to return a view or a copy.

Explanation

Under Copy-on-Write, col behaves as its own object. Writing to it triggers a private copy, so df still holds [1, 2, 3]. (On pandas 2.x without CoW enabled, the same code printed [99, 2, 3] because col was a view. If the version printed in the first cell is below 3.0, that is what you will see.)

Remember: check dtypes early (df.dtypes, df.info()); text is str in pandas 3.0; with Copy-on-Write, update data with a single .loc[...] = or with assign, never with chained brackets.

Reading data and first inspection

pandas readers turn files into DataFrames: pd.read_csv, pd.read_json, pd.read_parquet, pd.read_excel, pd.read_sql, and more. Each accepts a path, a URL, or a file-like object such as io.StringIO (which is how this page feeds them data, since the browser has no files).

read_csv guesses types by default. Guessing is convenient for exploration but dangerous in a pipeline: a ZIP code like 02134 loses its leading zero as an integer, and one bad value turns a numeric column into text. Production code states its expectations:

df = pd.read_csv(
    "orders.csv",
    usecols=["order_id", "date", "region", "amount"],   # read only what you need
    dtype={"order_id": "str", "region": "category", "amount": "float64"},
    parse_dates=["date"],                                # turn text into datetime64
    na_values=["", "n/a", "-"],                          # extra spellings of "missing"
)

Read the output like a detective. info() shows qty has 5 non-null values out of 6 and became float64 (because of the missing value). value_counts() reveals inconsistent casing in region. describe() gives quartiles that hint at outliers. These three calls are the first thing to run on any new dataset.

JSON and Parquet

JSON from APIs is usually a list of records: pd.read_json(io.StringIO(text)) handles that, and pd.json_normalize(records) flattens nested objects into dotted column names. Parquet is a binary, columnar, compressed file format that stores dtypes with the data, so nothing has to be guessed and reading a few columns skips the rest of the file. It is the standard format for analytical data; M52 explains how it works inside.

Remember: pass dtype, parse_dates, usecols, and na_values explicitly in pipelines; then run head, info, describe, and value_counts before trusting anything.

Selecting rows and columns

pandas has several selection tools. Learn them precisely, because the differences matter:

SyntaxSelects byExample
df["col"], df[["a", "b"]]column label(s)a Series, or a DataFrame for a list
df.loc[rows, cols]labels; slices include the enddf.loc["A-1002":"A-1004", "qty"]
df.iloc[rows, cols]integer positions; slices exclude the end, like listsdf.iloc[0:2, -1]
df[mask], df.loc[mask, cols]a boolean Series aligned to the rowsdf[df["qty"] > 2]
df.query("expr")a boolean expression in a stringdf.query("qty > 2 and region == 'North'")
Why are they different?

Label slices include both endpoints because "the next label after c" has no meaning for arbitrary labels. Position slices follow normal Python rules: 1:2 is just position 1.

Boolean masks

A comparison on a Series produces a mask: a Series of True/False with the same index. Indexing with the mask keeps the True rows. Combine masks with & (and), | (or), and ~ (not), and always parenthesize each comparison, because & binds more tightly than >. Python's and/ or do not work on Series: they ask "is this whole Series true?", which is ambiguous, and raise ValueError.

Remember: loc is labels (inclusive slices), iloc is positions (exclusive slices); masks use & | ~ with parentheses; query reads well for long filters.

Cleaning: missing values, types, duplicates, strings, categories

Data cleaning is most of the work in real analysis. Each fix should be a deliberate, documented rule, because every rule changes the answer. The main tools:

Missing values

isna()/notna() find them; fillna(value) replaces them; dropna(subset=[...]) removes rows; ffill() carries the last value forward (useful for sensor readings).

Types

astype("int64") converts when every value is valid; pd.to_numeric(s, errors="coerce") and pd.to_datetime(s, errors="coerce") turn bad values into NaN/NaT so you can count and handle them.

Duplicates

duplicated(subset=..., keep="first") marks repeats; drop_duplicates(...) removes them. Decide which columns define "the same record".

Strings

The .str accessor vectorizes string methods: .str.strip(), .str.lower(), .str.title(), .str.replace(), .str.contains(), .str.extract(regex).

Notice what errors="coerce" did: 2024-02-30 and not known became NaT instead of crashing the whole load. That is usually what you want, as long as you count the coerced values afterwards. Silent coercion that turns half a column into NaN is a common way to produce confident, wrong results.

Categorical data

A column with a few distinct values repeated many times (region, status, size) can be stored as category: pandas keeps one small table of the distinct values and an integer code per row. It saves memory, speeds up groupby, and lets you declare a meaningful order.

Remember: coerce bad values to missing, then count them; normalize strings with .str; define duplicates by the columns that identify a record; use category for repeated labels.

Creating columns: vectorize first

New columns come from expressions over existing ones. pandas operations on whole columns are vectorized: the loop runs in compiled code over the underlying arrays, just like NumPy. The alternatives, in rough order of speed:

  1. Vectorized arithmetic and methods: df["qty"] * df["price"], .str, .dt, np.where(cond, a, b), np.select, pd.cut. Fastest; use these whenever possible.
  2. Series.map(dict_or_func): element-wise lookup; fine for mapping codes to labels.
  3. apply(func): calls a Python function per element (or per row with axis=1). Flexible but runs at Python speed: often 50-500x slower than vectorized code.
  4. iterrows() loops: slowest and easiest to get wrong. Avoid for computation.

df.assign(name=expr, ...) returns a new frame with added or replaced columns. Inside assign, a lambda d: ... receives the frame as it is at that point, so later columns can use earlier ones.

Remember: express column logic as whole-column operations; reach for apply only when no vectorized form exists, and measure when in doubt.

groupby: split, apply, combine

"Revenue per region", "average delay per airline", "first purchase per customer": most summaries follow one pattern, named split-apply-combine by Hadley Wickham. Split the rows into groups by a key, apply a function to each group, combine the results into a new table.

original rows North 2 South 1 North 5 East 4 South 2 North 10 split by region North: 2, 5, 10 South: 1, 2 East: 4 apply sum() 1734 combine region | qty East | 4 North | 17 South | 3 df.groupby("region")["qty"].sum() : group keys become the result's index (sorted by default)
Split-apply-combine: the core of groupby.

Here is the same idea in plain Python, which is exactly what pandas does for you, but in compiled code. Step through it to see the dictionary of groups grow:

Three kinds of "apply"

MethodEach group returnsResult shapeUse for
agg / sum, mean, ...one value per columnone row per groupsummary tables
transforma value per original rowsame length as the inputgroup-relative columns: share of total, z-score within group, group mean beside each row
filterTrue/False for the whole groupa subset of the original rows"keep customers with at least 3 orders"

Named aggregations make summary tables readable: each keyword becomes an output column, given as (input_column, function).

Remember: agg reduces each group to a row, transform broadcasts a group result back to every row, filter keeps or drops whole groups. Named aggregations make output columns self-documenting.

Combining and reshaping tables

merge: SQL-style joins

pd.merge(left, right, on="key", how=...) matches rows whose key values are equal. The how argument decides what happens to rows without a partner:

inner left right outer left table circle on the left, right table on the right; shaded rows are kept
Inner keeps matches only; left keeps every left row; right keeps every right row; outer keeps everything.

Unmatched columns are filled with missing values. The silent danger is duplicate keys: if a customer ID appears twice in the customer table, every one of that customer's orders is duplicated after the join, and revenue totals inflate without any error. Defend against it with validate=:

indicator=True adds a _merge column saying whether each row came from left_only, right_only, or both: a quick audit of what did not match.

concat: stacking tables

pd.concat([df1, df2]) stacks frames vertically (same columns, more rows), aligning on column names. ignore_index=True renumbers rows; keys=["jan", "feb"] labels where each piece came from. With axis=1 it places frames side by side, aligning on the row index. Collect pieces in a list and concat once; concatenating inside a loop copies everything each time (quadratic cost).

Reshaping: wide and long

The same data can be long (one row per observation: date, city, temperature) or wide (one row per date, one column per city). Long format is best for groupby and plotting libraries; wide format is best for humans reading a table and for computing differences between columns.

Remember: pick how deliberately, always validate= joins, audit with indicator=True; concat a list once; long format for computing, wide format for reading.

Time series

When the row index is a DatetimeIndex, pandas gains a whole toolkit for time. Start by parsing text into real timestamps with pd.to_datetime, preferably with an explicit format= (faster and unambiguous: is 03/04/2025 March 4 or April 3?).

Remember: parse with an explicit format, use resample for calendar buckets and rolling for moving windows, and keep timestamps in UTC internally.

Method chaining and performance

Because most pandas methods return a new object, you can write an analysis as one readable chain, top to bottom, each line one step. Wrap the chain in parentheses so each method sits on its own line. Use assign for new columns, query or loc[lambda d: ...] for filters, and pipe(func) to drop your own functions into the chain.

Performance rules of thumb

  1. Vectorize. Whole-column operations beat apply, which beats loops.
  2. Read less. usecols=, the right dtype=, and Parquet with columns= avoid loading what you will not use.
  3. Use compact dtypes. category for repeated labels; str (Arrow-backed) instead of object; downcast with pd.to_numeric(s, downcast="integer") when it matters.
  4. Build once. Append rows to a Python list and create the DataFrame once; concat a list of frames once. Growing a frame row by row is quadratic.
  5. Measure with df.memory_usage(deep=True) and time.perf_counter(). When data no longer fits comfortably in memory, or you need multi-core speed, look at Polars and DuckDB (M52 and M61).

Remember: chains read like a recipe and avoid stale intermediate variables; performance comes from vectorization, reading less, and compact dtypes.

Practice

Each exercise runs in your browser with pandas (the first run downloads it). Data is built inline.

Write top_products(df, n) that takes a DataFrame with columns product, qty, and unit_price, and returns a Series of total revenue (qty * unit_price) per product, largest first, keeping only the top n. The Series index holds product names.

Hint

Compute revenue with assign, then groupby("product")["revenue"].sum(), then sort_values(ascending=False).head(n) (or nlargest(n)).

Show solution
import pandas as pd

def top_products(df, n):
    revenue = df.assign(revenue=df["qty"] * df["unit_price"])
    return revenue.groupby("product")["revenue"].sum().nlargest(n)

Write clean_prices(s) that takes a Series of text prices such as "$1,234.50", " 12 ", "n/a", or a missing value, and returns a float64 Series: strip whitespace, remove $ and ,, and turn anything that is not a number into NaN. Keep the original index.

Hint

s.str.strip().str.replace(r"[$,]", "", regex=True), then pd.to_numeric(..., errors="coerce").

Show solution
import pandas as pd

def clean_prices(s):
    text = s.astype("str").str.strip().str.replace(r"[$,]", "", regex=True)
    return pd.to_numeric(text, errors="coerce").astype("float64")

Write add_share(df) that returns a new DataFrame with an extra column share: each row's amount divided by the total amount of its team. The result must have the same rows in the same order as the input, and the input must not change.

Hint

df.groupby("team")["amount"].transform("sum") returns a Series aligned with df; combine with assign.

Show solution
import pandas as pd

def add_share(df):
    totals = df.groupby("team")["amount"].transform("sum")
    return df.assign(share=df["amount"] / totals)

Write monthly_revenue(df). The input has text columns date (format YYYY-MM-DD; some values are invalid) and numeric amount. Drop rows whose date cannot be parsed, then return a Series of total amount per month, indexed by strings like "2025-01", in chronological order.

Hint

pd.to_datetime(df["date"], format="%Y-%m-%d", errors="coerce"), drop NaT, then group by dates.dt.strftime("%Y-%m") (string keys sort chronologically in this format).

Show solution
import pandas as pd

def monthly_revenue(df):
    dates = pd.to_datetime(df["date"], format="%Y-%m-%d", errors="coerce")
    valid = df.assign(month=dates.dt.strftime("%Y-%m"))[dates.notna()]
    return valid.groupby("month")["amount"].sum().sort_index()

Write revenue_by_region(orders, customers). orders has cust_id and amount; customers has cust_id and region. Left-join orders to customers with validate="many_to_one" (so a duplicated customer raises pd.errors.MergeError), label orders whose customer is unknown as region "Unknown", and return total amount per region as a dict.

Hint

pd.merge(orders, customers, on="cust_id", how="left", validate="many_to_one"), then fillna on region, then groupby. Convert with .to_dict().

Show solution
import pandas as pd

def revenue_by_region(orders, customers):
    joined = pd.merge(orders, customers, on="cust_id", how="left", validate="many_to_one")
    joined = joined.assign(region=joined["region"].fillna("Unknown"))
    return {str(k): float(v) for k, v in joined.groupby("region")["amount"].sum().items()}

Mini-project: clean and analyze a messy sales export

Mini-projectSales cleanup and summary

A small online shop exported a year of orders, and it is a mess. You will write a script that generates the messy file, cleans it with documented rules, and prints (or saves) a short analysis.

pixi init sales-cleanup && cd sales-cleanup
pixi add python=3.14 pandas numpy pyarrow
pixi run python make_data.py      # writes sales_raw.csv
pixi run python analyze.py        # cleans and summarizes

Generate the data (make_data.py): use numpy.random.default_rng(2025) to create ~5,000 orders with order_id, order_date, customer, region, product, qty, unit_price. Then deliberately damage it: random casing and whitespace in region/product, prices as text with $ and thousands separators, 2% blank quantities, 1% impossible dates, 1% duplicated orders, and a few negative quantities (refunds).

Clean and analyze (analyze.py): load with explicit dtypes, apply each cleaning rule as a small function that returns the cleaned frame and the number of rows it affected, then answer: revenue by month, top 5 products, revenue share by region, average order value per customer segment, and the refund rate per product.

  • The generator is deterministic (same seed, same file)
  • Loading uses dtype=, parse_dates or a later to_datetime(format=...)
  • Every cleaning rule reports how many rows it touched, and the counts match what the generator injected
  • No chained assignment: updates use .loc or assign
  • The summary uses groupby with named aggregations and one transform
  • Monthly revenue uses resample or to_period("M")
  • The cleaned data is saved as Parquet and reloads with the same dtypes

Stretch goals: write the whole analysis as one method chain with pipe; add pytest tests for each cleaning rule; compare memory before and after converting columns to category.

Quiz

Recap

Going further