Data Moves
Here are two small tables that we will use to learn the different ways you can work with a dataset. We will focus on one data move at a time. Each one is represented as:
| school | district | students | absent |
|---|---|---|---|
| Adams | North | 400 | 60 |
| Baker | North | 250 | 50 |
| Cole | North | 150 | 45 |
| Diaz | South | 600 | 60 |
| Evans | South | 100 | 30 |
| Ford | East | 300 | 75 |
| Grant | East | 200 | NaN |
| Hale | NaN | 350 | 70 |
NaN is how Python shows a missing value ("not a number"). Here, Grant's absences and Hale's district are missing.
In schools, what does one row represent?
| district | year | per_pupil |
|---|---|---|
| North | 2025 | 12,000 |
| South | 2024 | 9,500 |
| South | 2025 | 9,800 |
| East | 2025 | 10,200 |
In funding, what does one row represent?
“List the schools from largest to smallest.”
schools.sort_values("students", ascending=False)
schools.sort_values(...) | A dot means do this to the thing on the left: sort schools. |
"students" | Sort by this column. |
ascending=False | A named option: biggest first. Leave it out and the default (smallest first) applies. |
Before
| school | district | students | absent |
|---|---|---|---|
| Adams | North | 400 | 60 |
| Baker | North | 250 | 50 |
| Cole | North | 150 | 45 |
| Diaz | South | 600 | 60 |
| Evans | South | 100 | 30 |
| Ford | East | 300 | 75 |
| Grant | East | 200 | NaN |
| Hale | NaN | 350 | 70 |
Predict: which school will be in the first row?
After 8 → 8 rows
| school | district | students | absent |
|---|---|---|---|
| Diaz | South | 600 | 60 |
| Adams | North | 400 | 60 |
| Hale | NaN | 350 | 70 |
| Ford | East | 300 | 75 |
| Baker | North | 250 | 50 |
| Grant | East | 200 | NaN |
| Cole | North | 150 | 45 |
| Evans | South | 100 | 30 |
What changed: only the order. But the order of rows is what people read first, so it shapes the story.
“Keep only schools with at least 250 students.”
schools[schools["students"] >= 250]
schools[ ... ] | Square brackets with a condition inside: keep the rows where it is true. |
schools["students"] | The students column. Quoted name in brackets = a column. |
>= 250 | The condition: at least 250. |
Before
| school | district | students | absent |
|---|---|---|---|
| Adams | North | 400 | 60 |
| Baker | North | 250 | 50 |
| Cole | North | 150 | 45 |
| Diaz | South | 600 | 60 |
| Evans | South | 100 | 30 |
| Ford | East | 300 | 75 |
| Grant | East | 200 | NaN |
| Hale | NaN | 350 | 70 |
Predict: how many rows will come out?
After 8 → 5 rows
| school | district | students | absent |
|---|---|---|---|
| Adams | North | 400 | 60 |
| Baker | North | 250 | 50 |
| Diaz | South | 600 | 60 |
| Ford | East | 300 | 75 |
| Hale | NaN | 350 | 70 |
What can go wrong: the question quietly narrows. Any average you compute next describes schools with at least 250 students, not schools. Nothing in the output table says so.
“Show just each school's name and number of students.”
schools[["school", "students"]]
[["school", "students"]] | A list of column names inside the brackets (note the double brackets): keep those columns. |
Before
| school | district | students | absent |
|---|---|---|---|
| Adams | North | 400 | 60 |
| Baker | North | 250 | 50 |
| Cole | North | 150 | 45 |
| Diaz | South | 600 | 60 |
| Evans | South | 100 | 30 |
| Ford | East | 300 | 75 |
| Grant | East | 200 | NaN |
| Hale | NaN | 350 | 70 |
Predict: how many rows will come out?
After 8 → 8 rows
| school | students |
|---|---|
| Adams | 400 |
| Baker | 250 |
| Cole | 150 |
| Diaz | 600 |
| Evans | 100 |
| Ford | 300 |
| Grant | 200 |
| Hale | 350 |
What changed: only what you see. The data behind the table is untouched.
“Calculate each school's absence rate.”
schools["absent_rate"] = schools["absent"] / schools["students"]
schools["absent_rate"] = ... | A single = stores a result. Here it stores a new column named absent_rate. |
schools["absent"] / schools["students"] | Divide one column by another, row by row. |
Before
| school | district | students | absent |
|---|---|---|---|
| Adams | North | 400 | 60 |
| Baker | North | 250 | 50 |
| Cole | North | 150 | 45 |
| Diaz | South | 600 | 60 |
| Evans | South | 100 | 30 |
| Ford | East | 300 | 75 |
| Grant | East | 200 | NaN |
| Hale | NaN | 350 | 70 |
Predict: what will Cole's absent_rate be?
After 8 → 8 rows, one new column
| school | district | students | absent | absent_rate |
|---|---|---|---|---|
| Adams | North | 400 | 60 | 0.15 |
| Baker | North | 250 | 50 | 0.20 |
| Cole | North | 150 | 45 | 0.30 |
| Diaz | South | 600 | 60 | 0.10 |
| Evans | South | 100 | 30 | 0.30 |
| Ford | East | 300 | 75 | 0.25 |
| Grant | East | 200 | NaN | NaN |
| Hale | NaN | 350 | 70 | 0.20 |
What can go wrong: this is where definitions live. Divide by something else (the district's students, say, instead of the school's) and every rate changes, with nothing in the output to tell you which definition you got.
“Total students across all the schools.”
schools["students"].sum()
schools["students"] | The students column. |
.sum() | Add up every value in it. Other summaries work the same way: .mean(), .max(), .count(). |
Before
| school | district | students | absent |
|---|---|---|---|
| Adams | North | 400 | 60 |
| Baker | North | 250 | 50 |
| Cole | North | 150 | 45 |
| Diaz | South | 600 | 60 |
| Evans | South | 100 | 30 |
| Ford | East | 300 | 75 |
| Grant | East | 200 | NaN |
| Hale | NaN | 350 | 70 |
Predict: what will come out?
After 8 rows → 1 number
2,350
What changed: the table is gone. One number now stands for all eight schools. A whole-table total, average or count is the simplest summary, and often the first number a report leads with.
“Total students in each district.”
schools.groupby("district")["students"].sum()
.groupby("district") | Split the rows into one pile per district. On its own this produces nothing you can see. |
["students"].sum() | Apply a sum to the students column in each pile, then combine the answers into a new table. |
Before (coloured by district)
| school | district | students | absent |
|---|---|---|---|
| Adams | North | 400 | 60 |
| Baker | North | 250 | 50 |
| Cole | North | 150 | 45 |
| Diaz | South | 600 | 60 |
| Evans | South | 100 | 30 |
| Ford | East | 300 | 75 |
| Grant | East | 200 | NaN |
| Hale | NaN | 350 | 70 |
Predict: how many rows will come out?
Split → apply → combine
East
sum = 500
North
sum = 800
South
sum = 700
NaN
sum = dropped
After 8 → 3 rows
| district | students |
|---|---|
| East | 500 |
| North | 800 |
| South | 700 |
What changed: one row now represents a district, not a school. The three districts add up to 2,000 students, but the whole table adds up to 2,350. The missing 350 is Hale, and nothing in the output says so.
Group by is not a move on its own: it changes what the next move does, one group at a time. Here it turned one total (the last screen) into one total per district. In class you will see it paired with other moves.
“Add each district's funding per pupil to the schools table.”
schools.merge(funding, on="district", how="left")
schools.merge(funding, ...) | Match rows of schools to rows of funding. |
on="district" | Rows match when their district is the same. |
how="left" | Keep every school, even one with no match in funding. |
schools
| school | district | students |
|---|---|---|
| Adams | North | 400 |
| Baker | North | 250 |
| Cole | North | 150 |
| Diaz | South | 600 |
| Evans | South | 100 |
| Ford | East | 300 |
| Grant | East | 200 |
| Hale | NaN | 350 |
funding
| district | year | per_pupil |
|---|---|---|
| North | 2025 | 12,000 |
| South | 2024 | 9,500 |
| South | 2025 | 9,800 |
| East | 2025 | 10,200 |
Predict: how many rows will come out?
After 8 → 10 rows
| school | district | students | absent | year | per_pupil |
|---|---|---|---|---|---|
| Adams | North | 400 | 60 | 2025 | 12,000 |
| Baker | North | 250 | 50 | 2025 | 12,000 |
| Cole | North | 150 | 45 | 2025 | 12,000 |
| Diaz | South | 600 | 60 | 2024 | 9,500 |
| Diaz | South | 600 | 60 | 2025 | 9,800 |
| Evans | South | 100 | 30 | 2024 | 9,500 |
| Evans | South | 100 | 30 | 2025 | 9,800 |
| Ford | East | 300 | 75 | 2025 | 10,200 |
| Grant | East | 200 | NaN | 2025 | 10,200 |
| Hale | NaN | 350 | 70 | NaN | NaN |
What can go wrong: a match can multiply rows. Total students in this table: 3,050, up from 2,350. Any sum or average from here counts Diaz and Evans twice. The question to ask of any match: did the row count change?
| Move | Python you saw | |
|---|---|---|
| Five moves on a table | Sort rows | schools.sort_values("students") |
| Keep rows | schools[schools["students"] >= 250] | |
| Keep columns | schools[["school", "students"]] | |
| Add a column | schools["absent_rate"] = ... | |
| Summarize | schools["students"].sum() | |
| Group by | Group by + Summarize | schools.groupby("district")["students"].sum() |
| Combining two tables | Match | schools.merge(funding, on="district") |
Note: When AI writes code, the table usually has a generic name like df
(short for “data frame”, Python's word for a table) or data. It's just a name:
df[df["students"] >= 250] is the same Keep-rows move on a table called df.
You may have noticed that along the way, the table changed: Hale vanished in the Group by, and Diaz and Evans doubled in the Match.
After each step of an analysis, ask yourself and check: