Group vs Knockout Scoring at a Live World Cup, with groupby
What you'll build
One pandas groupby over a real snapshot of the 2026 World Cup's first 96 matches - goals per game, both-teams-scored rate, and goalless share by stage - plus a stage chart that tests the 'knockouts tighten up' cliche on live data.

Every World Cup produces the same claim around this stage: "the knockouts tighten up." It's a perfect first analytics question because it's checkable with one groupby — and because the answer, on real data, is messier and more interesting than the cliché. We'll test it on a genuine snapshot of the tournament happening right now: the first 96 completed matches of the 2026 World Cup, through the round of 16, bundled as wc2026_results.csv so everything runs offline.
This builds on Grouping, Pivoting and Reshaping — the general tool — and applies it to a single sharp question. The rhythm here (derive columns, group, aggregate, chart with sample sizes showing) is the same one behind every stage-split, season-split or home/away-split table you've ever read.
Go deeper with the free textbook: Introduction to Soccer Metrics at DataField.dev.
-
Load the matches and derive the columns you wish existed
The CSV has one row per completed match: date, stage, the two teams, and the two scores. The three columns the question needs — total goals, did both teams score, was it goalless — don't exist yet, so make them. This derive-then-aggregate rhythm is most of real analysis.
python import pandas as pd df = pd.read_csv("wc2026_results.csv") df["total_goals"] = df["home_goals"] + df["away_goals"] df["both_scored"] = (df["home_goals"] > 0) & (df["away_goals"] > 0) df["goalless"] = df["total_goals"] == 0One row per real matchdate stage home away total_goals 2026-06-11 group Mexico South Africa 2 2026-06-12 group South Korea Czechia 3 2026-06-12 group Canada Bosnia-Herzegovina 2 2026-06-13 group United States Paraguay 5 ... 96 matches, stages: group, round of 32, round of 16
-
One groupby, five answers
Group by stage and aggregate five ways at once with named aggregations: how many games, how many goals, goals per game, the share where both teams scored, and the goalless share.
reindexputs the stages in tournament order instead of alphabetical — a tiny step that saves every reader of your table a double-take.python stage_order = ["group", "round of 32", "round of 16"] by_stage = ( df.groupby("stage") .agg(games=("total_goals", "size"), goals=("total_goals", "sum"), goals_per_game=("total_goals", "mean"), both_scored=("both_scored", "mean"), goalless=("goalless", "mean")) .reindex(stage_order) )The cliché, confrontedgames goals goals_per_game both_scored goalless stage group 72 215 2.99 0.542 0.097 round of 32 16 42 2.62 0.562 0.000 round of 16 8 23 2.88 0.500 0.125 Knockouts pooled: 24 games, 2.71 goals/game (groups: 2.99)
Read the middle column honestly. Scoring did dip when the knockouts began — 2.99 in the groups to 2.62 in the round of 32 — then bounced most of the way back (2.88) in the round of 16. Pooled, the knockouts sit about a quarter of a goal below the groups. That's a tightening, but a mild one: nothing like the strangled, goalless football the cliché promises.
-
Chart it, with the sample sizes on the bars
Three numbers deserve a chart with a warning label: the stages have wildly different sample sizes (72, 16, and 8 games), so print
non every bar. A rate from 8 games is a reading, not a truth — the honest chart says so on its face.python fig, ax = plt.subplots(figsize=(8.6, 5.4)) bars = ax.bar(by_stage.index, by_stage["goals_per_game"]) for b, (_, row) in zip(bars, by_stage.iterrows()): ax.annotate("n=%d games" % row["games"], (b.get_x() + b.get_width()/2, 0.12), ha="center") ax.axhline(df["total_goals"].mean(), ls="--") # tournament average
Data: Bundled snapshot (real 2026 World Cup results, ESPN public data), retrieved June 2026 -
Interrogate the cliché's strongest prediction: 0-0s
If knockouts really strangle football, goalless games should pile up there — teams playing for penalties. Count them by stage.
python zeros = df[df["goalless"]] print(zeros.groupby("stage").size())Where the 0-0s actually liveGoalless games: 8 of 96 (8%) stage group 7 round of 32 0 round of 16 1 The cliche says knockouts breed 0-0s. This snapshot: 7 in 72 group games, 1 in 24 knockout games - the knockouts have been DECISIVE, not cagey.
Seven of the tournament's eight goalless games happened in the group stage. The knockouts went 23 straight games without one before the round of 16's final tie broke the streak. On this snapshot, elimination football has been decisive, not cagey — the opposite of the cliché's strongest claim.
Troubleshooting
My stages come out in alphabetical order
groupby sorts group keys alphabetically by default, which puts "group" before "round of 16" before "round of 32" — tournament nonsense. That's what the .reindex(stage_order) is for: it reorders the result to the list you give it. The alternative is converting the column to an ordered pd.Categorical, which also fixes plots.
agg raises KeyError on my column names
Named aggregation's tuples are (column, function) — the first element must be a real column in the DataFrame, after your derives. If you renamed total_goals or skipped the derive step, the groupby can't find it. Print df.columns before aggregating.
Why do both_scored means look like 0.542 instead of percentages?
The mean of a boolean column is the share of True values, as a proportion. Multiply by 100 at display time if you want percent — keeping the underlying number a proportion makes later math (like pooling stages) simpler and safer.
Challenge yourself
Add a margin column (absolute goal difference) and aggregate it by stage — does the knockout football get closer even if it doesn't get lower-scoring? Then split the group stage by matchday (each team's 1st, 2nd, 3rd game — you can derive it with groupby.cumcount() per team) and test the folk claim that third group games go quiet once qualification is settled. Finally, re-pull a fresher snapshot after the tournament ends and re-run everything: the whole point of writing analysis as code is that the next 30 matches cost you nothing.
Get the code
Here's the complete, working script for this tutorial. It runs exactly as shown.
Download the finished script (81_world_cup_goals_by_stage.py)This script imports a small shared helper (and reads any bundled sample data) that live next to it in /downloads/ — grab these into the same folder so it runs as-is: sdt_common.py.


