Pandas & DataFrames
Loading, cleaning, joining, and transforming tabular data with pandas — taught from zero, starting with what a table even is, then building up to the group-by, join, and reshape moves that every ML dataset passes through before a model ever sees it.
Start here — what pandas actually is
Almost every dataset you will ever meet arrives as a table: rows going down, columns going across. A spreadsheet of sales. A CSV of sensor readings. A log of training runs. Before a model can learn anything from that table, somebody has to load it, find the broken rows, fix them, combine it with another table, and boil it down into summaries.
is the tool for that job. In one sentence:
pandas is a spreadsheet you drive with code instead of a mouse. Same idea — rows, columns, filters, sums, pivot tables — but every operation is a line of Python you can save, re-run, and hand to someone else. That's the entire pitch: reproducibility and scale, in exchange for typing instead of clicking.
A model is the meal. pandas is the kitchen — the washing, chopping, and measuring. It is unglamorous and it is where most of the time actually goes: practitioners routinely report spending the majority of a project on getting data into shape rather than on the model itself. Learning to move confidently in the kitchen is the single biggest speed-up available to a beginner.
Everything starts from plain language and a picture. Flip the Depth switch at the top when you want the formal statement of what an operation computes, or the edge cases that bite in production. Nothing is hidden for good — the deeper panels sit inline, one click away.
The Series — one labelled column
Start smaller than a table. A is one column: a list of values, where every value also carries a label.
That's the whole difference from a plain Python list. A list gives you values in positions 0, 1, 2. A Series gives you values and names for them.
A Python list is a row of hooks numbered 1, 2, 3 — to find your coat you must remember your number. A Series is a rack where each hook also has a name on it. You can still count along to hook 3, but you can also just walk up and ask for the one labelled accuracy. Both ways of getting at your coat stay available, and that double life is exactly why pandas exists.
Those labels live in an — the strip of names down the left-hand side. The Index matters more than it looks: when pandas combines two objects, it lines them up by label, not by position. Add two Series together and pandas matches alice to alice, no matter what order the rows happen to be in.
What does a Series have that a plain Python list does not?
Hint: Look at the left edge of a printed Series.
The DataFrame — the whole table
Stack several Series side by side, sharing one Index, and you get a — the object you will spend your whole pandas life holding.
A DataFrame is a dict of columns. The keys are column names; each value is a Series of the same length. Nearly every confusing error message in pandas becomes obvious once you picture it that way: df["accuracy"] hands you one column back, exactly like looking up a key in a dictionary.
Let's build one. Below is a small log of machine-learning training runs — the kind of table you produce ten times a week once you start experimenting. Run it:
Notice the four moves in that cell. They are the ones to make on every new dataset, before anything else:
| Command | What it tells you |
|---|---|
df.head() | What the data actually looks like |
df.shape | How many rows and columns you have |
df.dtypes | How pandas is interpreting each column |
df.describe() | Range, centre, and spread of the numbers |
is worth pausing on. If a column of numbers got read from a messy CSV as text, then df["price"].sum() will glue the strings end to end instead of adding them — and pandas will not complain. Checking dtypes early is the cheapest bug-prevention habit in data work.
Ask an older pandas (2.x) for the dtype of a text column and it says object — a catch-all meaning arbitrary Python objects. pandas 3.0, released in January 2026, infers a dedicated str dtype instead, which is faster and type-safe. The cell above prints its own version, so you can see which one you are on. Everything else on this page behaves identically either way.
You load a CSV and df.dtypes shows the column 'price' as object (or str) rather than float64. What is the most likely cause?
Selecting — getting at the part you want
You almost never want the whole table. You want some rows and some columns. pandas gives you three tools, and beginners mix them up constantly, so let's separate them cleanly.
There are exactly two ways to point at a row: by its name or by its place in line. pandas gives each way its own accessor so it never has to guess which you meant.
• .loc — select by label. Give me the row named 'ana'.
• .iloc — select by integer position. Give me the row sitting in slot 3.
The third tool is the one you will reach for most: a — a yes/no column that filters rows.
Take the runs table from above and ask for the runs that scored above 0.8, showing only the model and its accuracy.
- Evaluate the condition on every row.
accuracy > 0.8gives, going down the six rows:0.71 → False,0.74 → False,0.80 → False,0.83 → True,0.86 → True,0.88 → True. (Note that0.80 > 0.8isFalse— it is not strictly greater. Off-by-a-hair boundary mistakes like this are a classic source of quietly wrong row counts.) - Keep only the True rows. That leaves rows 4, 5, and 6.
- Keep only the requested columns. Drop everything except
modelandaccuracy.
Result: three rows — base 0.83, large 0.86, large 0.88.
In pandas that entire paragraph is one line: runs.loc[runs["accuracy"] > 0.8, ["model", "accuracy"]] — a mask for the rows, a list for the columns, separated by a comma.
Now run it and check the answer against what you worked out:
- Use
&and|, notandandor. Python'sanddemands a single true-or-false answer, but a mask is a whole column of them, so it raises an error.&and|combine the columns element by element. - Bracket each condition.
&binds more tightly than>in Python, soa > 1 & b > 2is silently parsed asa > (1 & b) > 2. Write(a > 1) & (b > 2).
You want the row sitting in the third position of the table, regardless of what it is called. Which accessor do you use, and why not the other one?
Hint: One letter of the name gives it away.
Vectorization — why you never loop over rows
Here is the habit that separates fast pandas from painfully slow pandas. Coming from ordinary Python, your instinct is to write a loop: for each row, do the thing. In pandas, you instead describe the operation on the whole column at once.
Writing a Python loop over 200,000 rows means 200,000 round trips between Python and the data — a conversation with every single worker, one at a time. Writing df["x"] * 2 hands the whole job down to compiled code that does all 200,000 multiplications in one tight pass, with no Python in the middle. Same answer, a fraction of the time.
That is , and it is the same idea you met in NumPy — pandas columns are NumPy arrays underneath, wearing labels.
Do not take it on faith. Time it yourself:
Why is df['x'] * 2 dramatically faster than looping over df['x'] in Python?
Missing data — the holes in every real dataset
Real tables have gaps. A sensor dropped out, a form field was left blank, a join found no match. pandas marks every such hole with .
This distinction decides whether your analysis is right or wrong. A missing temperature reading is not 0 °C — it is we do not know. Treat unknowns as zeros and you drag every average toward zero and invent a pattern that was never in the data. pandas takes the honest position: NaN contaminates arithmetic (anything plus unknown is unknown) but is skipped in summaries, so mean() averages the values you actually have.
Take this deliberately damaged version of the training log. Three of its twenty cells are missing:
Once you can see the holes, you have exactly three honest options:
| Option | Command | When it is the right call |
|---|---|---|
| Drop the rows | df.dropna() | Few rows affected, and you can afford to lose them |
| Fill the gaps | df.fillna(value) | The gap has a defensible stand-in — a median, a category like unknown |
| Leave them | do nothing | The summaries you need already skip NaN correctly |
Bare df.dropna() deletes a row if any column in it is missing. On a wide table with scattered gaps that can wipe out most of your data in one line — in the example below it takes five rows down to one. Almost always you want df.dropna(subset=["the_column_that_matters"]) instead.
Split-apply-combine — the group-by
Now the operation that earns pandas its keep. You rarely want a number for the whole table; you want one per group. Average accuracy per model. Total sales per region. Best score per student.
Every group-by is the same three moves, always in this order:
1. Split — deal the rows into piles by some key.
2. Apply — compute a number for each pile independently.
3. Combine — stack those numbers back into one small result table.
pandas calls the whole pattern split-apply-combine, and writes all three steps as a single line.
Tip the bag out and make piles by colour (split). Weigh each pile (apply). Write the weights on one list (combine). Nobody would weigh the whole bag and call it an answer about the whites — and that is exactly the mistake a table-wide average makes.
Group the six training runs by model and average their accuracy.
- Split into three piles by the
modelcolumn:small→ runs 1 and 2 → accuracies0.71, 0.74base→ runs 3 and 4 → accuracies0.80, 0.83large→ runs 5 and 6 → accuracies0.86, 0.88
- Apply the mean inside each pile:
- small:
- base:
- large:
- Combine into a three-row result, one row per group. pandas sorts the group keys alphabetically by default, so it comes back as
base, large, small— not in the order the groups first appeared. If that ordering matters to you, passsort=False.
One line: runs.groupby("model")["accuracy"].mean().
Those three numbers are the entire result — and seeing them side by side is the point of computing them:
Group-wise transforms — the move that feeds a model
transform deserves its own section, because it is the bridge from data cleaning to actual machine learning.
The problem: a raw number is often meaningless without its context. A score of 90 is excellent in a hard class and mediocre in an easy one. A model needs the relative number, not the absolute one.
A teacher who reports raw marks tells you little across different classes. A teacher who reports "two-thirds of a standard deviation above your class average" tells you something comparable everywhere. transform is how you grade on a curve — per group, with the group's own mean and spread.
The standard version of this is the , computed within each group:
Watch it work. Class A is tightly bunched, class B is spread wide, and the z-scores make the two comparable:
You want to add a column holding each row's group average, keeping every original row. Do you use agg or transform?
Hint: Ask what shape you need back.
Joining tables — merge
Your data almost never lives in one table. Facts get split across several — runs in one, model specifications in another, costs in a third — and you need them side by side.
A glues two tables together by a key: a column both tables have. pandas walks the key values, and wherever they match, it stitches the two rows into one wider row.
You have a list of attendees and a separate list of dinner choices, both keyed by name. Merging is walking down the attendee list, finding each person's dinner choice by name, and writing it on their badge. The only real question is what to do about mismatches — someone on one list but not the other — and that is precisely what the how argument decides.
You have six training runs keyed by model, and a specification table listing four models with their parameter counts:
| model | params_m |
|---|---|
| small | 12 |
| base | 120 |
| large | 1300 |
| huge | 7000 |
Merge the two on model, and note the mismatch: huge has a spec but no runs.
- An inner join keeps only matches. Each of the 6 runs finds its spec, so all 6 survive and each gains a
params_mcolumn.hugematches nothing and is dropped. Result: 6 rows. - A left join keeps everything on the left. Merge starting from the spec table and you keep all four models.
small,baseandlargeeach expand to their 2 runs — that is 6 rows — andhugesurvives as a single row withNaNin every run column. Result: 7 rows. - Watch the row count. It went from 6 to 7 without you asking. That is the number to check after every merge.
how= | Keeps | Reach for it when |
|---|---|---|
inner (default) | Only keys present in both | You need complete records and can drop the rest |
left | All left rows, filling gaps with NaN | Enriching a main table with optional extras |
right | All right rows | Same as left, sides swapped |
outer | All keys from either | Reconciling two sources and seeing what each is missing |
A merge is the single most common way to silently corrupt a dataset. If a key is duplicated on both sides, pandas produces the cross-product of the matches — 3 rows against 3 rows becomes 9 — and a table can balloon without a single error message. Print df.shape before and after, every time. When you expect a strict relationship, say so and let pandas enforce it: merge(..., validate="one_to_many") raises immediately if the assumption is violated.
Reshaping — long versus wide
The same data can be laid out two ways, and different tools want different layouts.
- Long (also called tidy): one row per observation, with columns naming the variable and its value.
run, metric, value. Databases and most plotting libraries love this. - Wide: one row per subject, one column per variable.
run, accuracy, minutes. Humans and spreadsheets prefer this.
Long format is the arrivals board — one line per event, endlessly scrolling. Wide format is the printed grid on the wall — one row per train, one column per station. Identical information, opposite ergonomics.
The gotchas that bite everyone
After merging two tables you notice the row count went from 1,000 to 1,340. What should you suspect first?
Where this shows up in ML
Every one of these is a pandas move wearing a machine-learning hat:
- Building a dataset is
read_csv→dropna→mergewith a labels table. - Feature engineering is
assign,groupby().transform(), andmap— the group-wise z-score above is a feature. - Train/test splitting by user or by date is a boolean mask over a column.
- Reporting per-class metrics is a
groupbyon the predicted label. - Auditing a model for fairness is a
groupbyon a demographic column, comparing the same metric across groups.
Explain split-apply-combine to a friend using the laundry-sorting picture, with no code and no pandas jargon. Then say in one sentence how transform differs from agg. If you stall on that last sentence, reread the group-wise transform section — the difference is the shape of what comes back.
- A Series is one column of values plus a label for each (its Index); a DataFrame is a dict of Series sharing one Index — a spreadsheet you drive with code.
- Check
head,shape,dtypes,describeon every new table. A wrong dtype is the quietest bug in data work. - Select rows by label with
.loc, by position with.iloc, and by condition with a boolean mask — combining conditions with&and|, each side bracketed. - Vectorize: state the operation on the whole column instead of looping over rows, and it runs in compiled code, many times faster.
- NaN means unknown, not zero. Summaries skip it;
dropna()is greedier than it looks; missingness is often information worth keeping. groupbyis split-apply-combine:aggreturns one row per group,transformreturns one value per row,filterkeeps or drops whole groups.mergejoins tables on a shared key;how=decides what happens to non-matches, and you check the row count afterwards, every time.- Never assign through a chain — do it in one step with
.loc.
Practice — and how to make it stick
• Retrieval practice: before rereading anything, close your eyes and name the three steps of split-apply-combine, and the difference between .loc and .iloc. Pulling it from memory is what builds the memory.
• Spaced repetition: mark this topic complete to add it to your Review queue, so it resurfaces right before you would have forgotten it.
• Interleaving: mix these exercises with NumPy drills rather than doing all of one then all of the other — pandas columns are NumPy arrays, and switching between the two views cements both.
- By hand, before any code: take the six-run table and predict what
runs.groupby("lr")["accuracy"].mean()returns — how many rows, what labels, what values. Then run it and check. - Break it on purpose: duplicate the
smallrow in theconfigstable and re-run the inner merge. Watch the row count grow, then addvalidate="one_to_many"and watch pandas refuse. - Real data: load any CSV you care about, run the four orientation commands, and write down one question the table can answer. Answer it with a single
groupby. - From scratch: implement
groupby(...).mean()yourself in plain Python with a dictionary of lists. Doing it the slow way once makes the fast way permanent.
Try it right here — edit and run the code, and if you get stuck or hit an error, ask Ada on the right: she can see your code and terminal output.
Next up: now that the data is in shape, learn to show it — Plotting & Visualization.