Build an Elo Rating System in Python

A tested Elo implementation: rate every Premier League team since 2015-16, add home advantage and turn the ratings into 1X2 probabilities.

Algo Bet Editorial · · Models · 3 min read · Updated: 11 October 2026

Elo ratings are the simplest serious football model: one number per team, updated after every match. In about thirty lines of Python you can rate every Premier League team since 2015-16 and turn the ratings into win, draw and loss probabilities. This tutorial uses the clean matches table from Load and Clean Football Data With pandas.

How Elo works

Each team has a rating, starting at 1500. Before a match, the difference between the two ratings gives the home team's expected score:

E = 1 ÷ (1 + 10^(−(R_home + HFA − R_away) ÷ 400))

where HFA is a home-advantage bonus in rating points. After the match, each rating moves by K × (actual − expected), where the actual score is 1 for a win, 0.5 for a draw and 0 for a loss. K controls how fast ratings react.

The code

K, HFA, DRAW = 20, 60, 0.25
ratings = {}
probs = []

for row in matches.itertuples():
    rh = ratings.get(row.HomeTeam, 1500.0)
    ra = ratings.get(row.AwayTeam, 1500.0)
    e_home = 1 / (1 + 10 ** (-(rh + HFA - ra) / 400))

    # turn the expected score into three probabilities
    p_home = max(e_home - DRAW / 2, 0.02)
    p_away = max(1 - e_home - DRAW / 2, 0.02)
    s = p_home + DRAW + p_away
    probs.append((p_home / s, DRAW / s, p_away / s))

    score = {"H": 1.0, "D": 0.5, "A": 0.0}[row.FTR]
    ratings[row.HomeTeam] = rh + K * (score - e_home)
    ratings[row.AwayTeam] = ra - K * (score - e_home)

matches[["elo_h", "elo_d", "elo_a"]] = pd.DataFrame(probs)

Two design choices to notice:

  • Probabilities are recorded before the update. Each forecast uses only matches played earlier, so there is no leakage from the future.
  • Draws are handled crudely. Elo has no concept of a draw, so we give every match a fixed draw probability of 25% and split the rest using the expected score. It is simple and it works, but it is the first thing to improve.

The ratings at the end of 2025-26

top = sorted(ratings.items(), key=lambda kv: -kv[1])[:5]
print([(team, round(r)) for team, r in top])

Our output:

Team Elo rating
Arsenal 1774
Man City 1753
Liverpool 1664
Man United 1657
Aston Villa 1646
Bar chart of the five highest Elo ratings at the end of the 2025-26 Premier League season
Elo ratings after the final match of 2025-26, from the code in this tutorial.

That matches the shape of the 2025-26 table, in which Arsenal won the title ahead of Manchester City.

Choosing the parameters

We tested a few settings by scoring forecasts from 2019-20 to 2025-26 (2,660 matches) with the Brier score, where lower is better:

Setting Brier score
K = 10 0.5923
K = 20 0.5890
K = 30 0.5894
No home advantage (HFA = 0) 0.5938
HFA = 60, new teams start at 1450 0.5887
HFA = 100, new teams start at 1450 0.5972

Watch out: We tuned these settings on the same seasons we then report, which flatters the best numbers slightly. For an honest result, choose parameters on earlier seasons and test on later ones, as explained in Score Your Model With the Brier Score.

Key point: Home advantage matters: removing it makes the forecasts clearly worse. Starting promoted teams a little below 1500 helps slightly, because new teams in the data are mostly promoted sides.

Ideas to improve it

  1. Model draws properly, for example with an ordered logit on the rating difference.
  2. Scale K with the goal difference, so a 4–0 moves ratings more than a 1–0.
  3. Regress ratings towards the mean between seasons.

To compare Elo with goal-based forecasts, try the Poisson match predictor.

  • Elo needs only results and gives every team one number, updated after each match.
  • Record forecasts before updating, so every prediction uses past data only.
  • In our test, K = 20 and a home advantage of about 60 points worked best; draws are the weak point.

Sources

18+ only. Models do not guarantee results. See our Responsible Gambling page.