Group vs Knockout Scoring at a Live World Cup, with groupby

SoccerBeginnerPython~5 min read

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.

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.
Data: Bundled snapshot (real 2026 World Cup results, ESPN public data), retrieved June 2026

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.

  1. 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"] == 0
    One row per real match
          date 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
  2. 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. reindex puts 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é, confronted
                 games  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.

  3. 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 n on 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
    Bar chart of 2026 World Cup goals per game by stage: group stage 2.99 over 72 games, round of 32 down at 2.62 over 16 games, round of 16 back up at 2.88 over 8 games, with the tournament average of 2.92 drawn as a dashed line
    Data: Bundled snapshot (real 2026 World Cup results, ESPN public data), retrieved June 2026
  4. 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 live
    Goalless 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.

Written by C. B. Zakarian

C. B. Zakarian is an independent analyst who writes about what he can measure: ball sports and the player-run economies inside Roblox. He builds every model, chart, and calculator here himself from public data, shows the working, and never invents a number. When the data can't answer a question, he says so. On SportsDataTutorials, that means tutorials where every line of code was run against real data before it was published. More about this site →

More Soccer tutorials

A team's completed passes drawn as arrows on a proper pitch with mplsoccer.
Soccer Intermediate

Draw a Pass Map with mplsoccer

Filter a match's passes from StatsBomb event data and draw them as arrows on a correctly-proportioned pitch using mplsoccer, with StatsBomb attribution.

~7 min