pandas: DataFrames, filtering & groupby
Load a CSV, select and filter rows, total by group, and survive missing values.
A DataFrame is a dict of columns
Stop picturing a spreadsheet and picture a dict. A DataFrame maps column names to columns, and each column is a Series: a numpy array of one dtype, plus a labelled index that lines its values up with every other column. Everything from the numpy lesson still applies, one column at a time. The index is the piece that has no list equivalent, and it is what makes rows line up after a filter, a join or a sort.
Loading from a string buffer
read_csv takes a path, but it also takes any file-like object. StringIO makes a string look like a file, which is how you load an API response, or test a parser, without a fixture on disk:
import pandas as pd
from io import StringIO
text = """region,rep,amount
west,Ada,100
east,Sam,250
west,Mo,50
"""
df = pd.read_csv(StringIO(text))
df.dtypes # region object, rep object, amount int64
df.head() # the first rows
df.shape # (3, 3)
read_csv infers a dtype per column from the values it sees, which is convenient right up until a blank cell changes its mind.
Selecting
df["amount"] # a Series (one column)
df[["region", "amount"]] # a DataFrame (a list of columns, hence the double brackets)
df.loc[0, "amount"] # by label: row index 0, column "amount"
df.iloc[0, 2] # by position: first row, third column
The double brackets trip everyone once. df["a"] asks for one column and gets a Series; df[["a"]] passes a list of names and gets a one-column DataFrame back.
Filtering with a boolean mask
Compare a column, then index the frame with the result:
big = df["amount"] > 100 # a Series of booleans
df[big] # the rows where it is True
df[(df["amount"] > 100) & (df["region"] == "east")]
Two rules for combining masks: use & and | rather than and and or (those ask for one truth value and raise on a Series), and parenthesize each comparison, because & binds tighter than >.
Reading with a mask, writing with .loc
Reading through two selections is fine. Writing through two selections is not, because the first one may hand you a copy. Do the whole thing in one indexer:
df.loc[df["amount"] > 100, "amount"] = 0 # one step, writes into df
groupby: split, apply, combine
df.groupby("region")["amount"].sum()
# region
# east 250
# west 150
groupby splits the rows by key, applies an aggregation to each group, and combines the answers into a new indexed result. In plain Python this is the defaultdict accumulate loop you already know; groupby is that loop with a name and a fast implementation. .agg({"amount": ["sum", "mean"], "rep": "count"}) runs several aggregations at once.
Missing values
A blank cell becomes NaN. NaN is a float, it never equals anything (not even itself), and it propagates through arithmetic. So df[df["amount"] == np.nan] matches nothing at all, no matter how many blanks there are:
df["amount"].isna() # the correct test, a boolean mask
df["amount"].fillna(0) # substitute a value
df.dropna(subset=["amount"]) # drop the rows that are missing it
What runs where
pandas is not bundled into the Python build behind this browser sandbox, so import pandas fails here (and it would pull numpy in with it). In a real environment it is one pip install pandas (or uv add pandas) away, using the setup from "Running Python & installing packages". The exercises below build the same three mechanics over lists of dicts, missing values and all, so the model you take to the terminal already accounts for the parts that bite.
Interview nuance: for a data role, the question behind the question is almost always about missing data. Anyone can write groupby("region").sum(). The signal is knowing that a missing group key silently removes the row from the answer, that a missing value is skipped by the aggregation instead of poisoning it, and that one blank cell turns an integer column into float64 and quietly breaks a join. Say what the default does, then say how you would check the total.
# pandas is not bundled into this browser sandbox, so this demo builds the same three
# moves over plain dicts: load from a text buffer, mask, and total by group.
import csv
import io
text = """region,rep,amount
west,Ada,100
east,Sam,250
west,Mo,50
"""
rows = [
{"region": r["region"], "rep": r["rep"], "amount": int(r["amount"])}
for r in csv.DictReader(io.StringIO(text))
]
print("rows: ", rows)
print("mask: ", [row["amount"] > 100 for row in rows])
print("big: ", [row["rep"] for row in rows if row["amount"] > 100])
totals = {}
for row in rows:
totals[row["region"]] = totals.get(row["region"], 0) + row["amount"]
print("group:", totals)Apply
Your turn
The task this lesson builds to.
Warm-up: implement infer_dtype(cells), the rule read_csv uses to pick a column's dtype.
cells is the list of raw text values for one column. Return "int64" when every cell is
integer-looking (digits with an optional leading -) and none is blank, "float64" when the
non-blank cells are all integer-looking but at least one cell is blank, and "object" otherwise.
Treat a whitespace-only cell as blank.
3 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
The weekly sales export arrives as CSV text in an API response rather than a file on disk, and
two cells came through blank. Implement the three helpers in frame/table.py: read_csv (typed row
dicts, blanks becoming None), filter_rows (a boolean-mask filter that a missing value never
passes), and group_sum (totals per key, dropping rows whose key is missing and counting a missing
value as zero). The README spells out each rule. Some tests are hidden.
3 hints and 2 automated checks are waiting in the workspace.