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.
- Explain the pandas data model: Index, Series, DataFrame, dtypes, and index alignment
- Describe the pandas 3.0 defaults (Copy-on-Write and the
strdtype) and write code that respects them - Load CSV, JSON, and Parquet with explicit types, then inspect and select data with
loc,iloc, masks, andquery - Clean missing values, wrong types, duplicates, and messy strings, and create columns with vectorized operations
- Summarize with
groupby(named aggregations andtransform), combine withmerge/concat, and reshape with pivot and melt - Work with time series: parsing, resampling, rolling windows, and time zones
- Write readable method chains and know the main performance rules
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.
- An Index is an immutable sequence of labels. It is how pandas finds rows (and columns). It can hold strings, integers, dates, or combinations of several levels (a MultiIndex).
- A Series is one column: a one-dimensional array of values plus an Index of row labels, a dtype (data type), and an optional name.
- A DataFrame is a table: several Series that share one row Index, stored under a second Index of column labels. Each column has its own dtype, so a DataFrame can mix text, numbers, and dates.
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:
| dtype | Holds | Missing value | Notes |
|---|---|---|---|
int64 | whole numbers | none (cannot hold NaN) | a missing value forces float64 |
Int64 (capital I) | nullable whole numbers | pd.NA | opt in with dtype="Int64" |
float64 | real numbers | NaN | the NumPy default |
bool / boolean | True/False | none / pd.NA | masks are bool |
str | text | NaN | the default for text in pandas 3.0 |
category | a small set of repeated labels | NaN | stores integer codes plus a lookup table |
datetime64[ns], datetime64[us] | timestamps | NaT | optionally time-zone aware |
object | arbitrary Python objects | None/NaN | slow; 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:
- Modifying a Series you pulled out of a DataFrame never changes the DataFrame.
- Chained assignment such as
df["qty"][0] = 10ordf[df.qty > 1]["price"] = 0never updatesdf. pandas emits aChainedAssignmentErrorwarning to tell you. Assign in one step withdf.loc[row_selector, "col"] = value, or build a new frame withassign.
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:
| Syntax | Selects by | Example |
|---|---|---|
df["col"], df[["a", "b"]] | column label(s) | a Series, or a DataFrame for a list |
df.loc[rows, cols] | labels; slices include the end | df.loc["A-1002":"A-1004", "qty"] |
df.iloc[rows, cols] | integer positions; slices exclude the end, like lists | df.iloc[0:2, -1] |
df[mask], df.loc[mask, cols] | a boolean Series aligned to the rows | df[df["qty"] > 2] |
df.query("expr") | a boolean expression in a string | df.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:
- Vectorized arithmetic and methods:
df["qty"] * df["price"],.str,.dt,np.where(cond, a, b),np.select,pd.cut. Fastest; use these whenever possible. Series.map(dict_or_func): element-wise lookup; fine for mapping codes to labels.apply(func): calls a Python function per element (or per row withaxis=1). Flexible but runs at Python speed: often 50-500x slower than vectorized code.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.
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"
| Method | Each group returns | Result shape | Use for |
|---|---|---|---|
agg / sum, mean, ... | one value per column | one row per group | summary tables |
transform | a value per original row | same length as the input | group-relative columns: share of total, z-score within group, group mean beside each row |
filter | True/False for the whole group | a 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:
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=:
validate="one_to_one": keys unique on both sides.validate="many_to_one": keys unique on the right (orders to customers: the usual case).validate="one_to_many","many_to_many"(the last checks nothing).
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.
pivot_table(index=, columns=, values=, aggfunc=): long to wide, aggregating duplicates.melt(id_vars=, var_name=, value_name=): wide to long.stack()/unstack(): move the innermost column level into the row index and back.
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?).
- The
.dtaccessor exposes parts:.dt.year,.dt.month,.dt.day_name(),.dt.hour,.dt.to_period("M"). resample(rule)isgroupbyfor time buckets:"D"daily,"W"weekly,"MS"month start,"h"hourly. Follow it with an aggregation.rolling(window)computes moving statistics over the last n rows (or a time span like"7D"), smoothing out noise to reveal a trend.shift(1)andpct_change()compare each period with the previous one.- Time zones:
tz_localize("America/New_York")declares which zone naive timestamps are in;tz_convert("UTC")converts an aware timestamp to another zone. Store and compute in UTC; convert for display.
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
- Vectorize. Whole-column operations beat
apply, which beats loops. - Read less.
usecols=, the rightdtype=, and Parquet withcolumns=avoid loading what you will not use. - Use compact dtypes.
categoryfor repeated labels;str(Arrow-backed) instead ofobject; downcast withpd.to_numeric(s, downcast="integer")when it matters. - 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.
- Measure with
df.memory_usage(deep=True)andtime.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
Quiz
Recap
- A DataFrame is labelled, typed columns sharing a row Index; operations align on labels.
- pandas 3.0: Copy-on-Write is always on (no chained assignment) and text defaults to the
strdtype. - Load with explicit dtypes; inspect with
head,info,describe,value_counts. - Select with
loc(labels, inclusive),iloc(positions), masks, andquery. - Clean with coercion plus counting,
.strmethods, deliberate duplicate rules, and categories. - Summarize with
groupby:agg(named),transform,filter. - Combine with validated merges and one-shot concat; reshape with
pivot_table/melt. - Time series:
to_datetime,resample,rolling, UTC internally.
Going further
- pandas User Guide (especially "Copy-on-Write" and "Group by")
- pandas release notes for the 3.0 changes
- Python for Data Analysis, 3rd ed. by Wes McKinney (free online)
- Tidy Data by Hadley Wickham: the theory behind long vs wide
- Next: M52 Polars, Arrow & Columnar Data