Build a Knockout Bracket Simulator in Python: From 32 Teams to Title Odds
Play a 32-team bracket ten thousand times and read off the title odds.
A knockout bracket is the cruellest sampling device in sport. Thirty-two teams enter the 2026 World Cup's round of 32; one survives five wins to lift the trophy, and a single bad ninety minutes ends everyone else's tournament. No team's title chance can be read off a bracket by eye — the path matters as much as the rating. The honest way to estimate it is to simulate: play the whole bracket round by round, do it ten thousand times, and count how often each team is the last one standing. This tutorial builds exactly that in Python.
From ratings to a match probability
Everything starts with one function: given two teams' ratings, what is the probability the first beats the second in a single match? The workhorse model is a logistic one, the same shape used by Elo. The larger the rating gap, the more lopsided the win probability, following an S-curve that never quite reaches 0 or 1 — because in football, anyone can beat anyone on the day. If ratings are on an Elo-style scale, the formula is a tidy one-liner.
The 400 is the Elo scale constant: a 400-point edge makes a team roughly a 10-to-1 favourite. Where the logic of these ratings comes from — and why Elo and SPI disagree on purpose — is the subject of soccer power ratings: Elo, SPI, and why they disagree. For the simulator we just need the function.
import numpy as np
rng = np.random.default_rng(7)
def match_prob(rating_a, rating_b):
"""Probability team A beats team B in one match (Elo logistic)."""
return 1.0 / (1.0 + 10 ** (-(rating_a - rating_b) / 400.0))
def play_match(team_a, team_b, ratings, seed_rng):
"""Return the winner of a single knockout match."""
p_a = match_prob(ratings[team_a], ratings[team_b])
return team_a if seed_rng.random() < p_a else team_b
There is no draw here, and that is deliberate: a knockout match cannot end level. The logistic gives the probability of A advancing — whether that comes via 90 minutes, extra time, or penalties is all folded into the single number. Modelling the penalty lottery separately is possible, but it adds noise more than insight at this level.
A toy field of 32 teams
We need thirty-two teams with ratings. Here is the essential honesty point of this whole piece: the field below is invented. It is not the real 2026 World Cup field, not based on any draw, qualification, or seeding, and not a prediction. The 2026 tournament has not been played — it kicks off on June 11, 2026 — so there are no real bracket results to use, and fabricating them would be dishonest. The toy ratings exist only to make the code runnable. Replace the names and numbers with your own and the simulator works unchanged.
# --- TOY FIELD (invented, NOT the real 2026 bracket) --------------
# 32 fake teams with made-up Elo-style ratings. Replace with your own.
team_names = [f"Team {i:02d}" for i in range(1, 33)]
base_ratings = np.linspace(1900, 1500, 32) # spread strong to weak
jitter = rng.normal(0, 30, size=32) # a little randomness
ratings = {name: float(r + j)
for name, r, j in zip(team_names, base_ratings, jitter)}
# Bracket seeding: the order teams are paired in round 1.
# In a real run this comes from the group-stage results. Here it is
# just the list order, so adjacent pairs (0v1, 2v3, ...) meet first.
bracket = list(team_names)
The structure of a real bracket — which group winner meets which runner-up — is fixed in advance by the tournament's bracket diagram. To keep this tutorial focused on the simulation engine, we treat the bracket list order as the seeding: team 0 plays team 1, team 2 plays team 3, and so on. When you plug in real results from simulating the groups, you arrange the thirty-two survivors into this list in the official bracket order, and everything downstream is identical.
Playing one bracket, round by round
A single-elimination bracket has a beautifully simple recursive shape: pair up the teams, play each match, collect the winners into a new (half-as-long) list, and repeat until one team remains. With thirty-two teams that is five rounds — round of 32, round of 16, quarter-final, semi-final, final — and the list halves each time: 32, 16, 8, 4, 2, 1.
def play_bracket(seeding, ratings, seed_rng):
"""Play a single-elimination bracket once. Return the champion."""
survivors = list(seeding)
while len(survivors) > 1:
next_round = []
# Pair adjacent teams: (0,1), (2,3), (4,5), ...
for i in range(0, len(survivors), 2):
winner = play_match(survivors[i], survivors[i + 1],
ratings, seed_rng)
next_round.append(winner)
survivors = next_round
return survivors[0]
That loop is the entire knockout. It works for any power-of-two field — 32, 16, 8 — without modification, because halving a power of two always lands cleanly on another. (The 2026 round of 32 is itself the product of a 48-team group stage; the unusual arithmetic that gets us there is unpacked in why 104 matches and a 48-team format.)
Monte Carlo: ten thousand tournaments
One bracket is one story. To estimate title odds we replay the bracket many times, each run using fresh random numbers, and tally how often each team wins. The fraction of tournaments a team wins is its estimated title probability — the core idea behind every published World Cup forecast, explained without code in how a World Cup simulation works.
from collections import Counter
def simulate(seeding, ratings, n_sims=10_000, seed=7):
seed_rng = np.random.default_rng(seed)
champions = Counter()
for _ in range(n_sims):
champ = play_bracket(seeding, ratings, seed_rng)
champions[champ] += 1
print(f"Title odds over {n_sims:,} simulated tournaments:\n")
for team, wins in champions.most_common(10):
print(f" {team:9s} {wins / n_sims:6.1%}")
simulate(bracket, ratings)
The output is a leaderboard of title probabilities. With the toy ratings spread evenly from strong to weak, the top-seeded teams will lead — but notice two things the simulation reveals that no eyeball estimate could. First, even the strongest team's title chance is far below 50%: winning five straight knockout matches is hard, and small per-match edges compound into long-shot odds. Second, two teams with nearly identical ratings can have meaningfully different title chances purely because of where the bracket places them — an easier half is worth real probability.
Sharpening the simulator
A few upgrades make this genuinely useful. Track every round, not just the title, by recording how far each team gets — that yields "reach the quarter-final" and "reach the final" probabilities, which are often more robust than the noisy title number. Feed in real ratings rather than the toy linspace: convert a power-rating table using the approach in soccer power ratings: Elo, SPI, and why they disagree. And chain it to the group stage so the whole pipeline runs end to end — simulate a World Cup group in Python produces the thirty-two survivors this bracket consumes, and the wider numbers behind the tournament live in World Cup 2026 by the numbers.
The honest framing never changes, though. A simulator is a machine for turning assumptions into probabilities; it cannot turn guesses into facts. Garbage ratings in, garbage odds out. The value is in being explicit about your inputs and letting ten thousand random tournaments tell you what those inputs imply — not in pretending the bracket has already been played.
Sources & further reading
- Free textbook: Chapter 20: Predictive Modeling — the theory behind this, at DataField.dev.
- SciPy documentation — NumPy's random number generation, used to drive the match and bracket draws.
- StatsBomb — background on rating teams and modelling match outcomes.
- StatsBomb open data — results data for building and validating your own team ratings.
- FBref — international match histories useful for calibrating an Elo-style rating.


