Load and Clean Football Data With pandas

A tested pipeline that downloads eleven Premier League seasons of CSV results, fixes the encoding and date pitfalls and builds one clean table.

Algo Bet Editorial · · Data Pipelines · 2 min read

Every football model starts with the same unglamorous job: getting results into a clean table. This tutorial builds a small, tested pipeline that downloads eleven Premier League seasons of match data from Football-Data.co.uk and fixes the three problems that broke our first attempt: an invisible byte at the start of each file, columns that change between seasons, and dates written in two different formats.

Tested with Python 3.14 and pandas 3.0.

The data

Football-Data.co.uk publishes one CSV per league and season. The Premier League files follow a simple URL pattern, where 2526 means 2025-26:

https://www.football-data.co.uk/mmz4281/2526/E0.csv

The columns we need:

Column Meaning
Date Match date
HomeTeam, AwayTeam Teams
FTHG, FTAG Full-time goals, home and away
FTR Result: H, D or A
AvgH, AvgD, AvgA Average market odds for each outcome (recent seasons only)

Step 1: download once, keep a local copy

from pathlib import Path
import urllib.request

SEASONS = ["1516", "1617", "1718", "1819", "1920", "2021",
           "2122", "2223", "2324", "2425", "2526"]
DATA = Path("data")
DATA.mkdir(exist_ok=True)

for s in SEASONS:
    target = DATA / f"E0_{s}.csv"
    if not target.exists():
        url = f"https://www.football-data.co.uk/mmz4281/{s}/E0.csv"
        urllib.request.urlretrieve(url, target)

Keeping local copies makes the pipeline reproducible and polite: you do not download the same files every time you run your code.

Step 2: load each season safely

import pandas as pd

COLS = ["Date", "HomeTeam", "AwayTeam", "FTHG", "FTAG", "FTR",
        "AvgH", "AvgD", "AvgA"]

def load_season(path):
    head = pd.read_csv(path, nrows=0, encoding="utf-8-sig",
                       encoding_errors="replace").columns
    df = pd.read_csv(path, usecols=[c for c in COLS if c in head],
                     encoding="utf-8-sig", encoding_errors="replace")
    df["Season"] = path.stem[-4:]
    return df.dropna(subset=["FTR"])

Three details matter here:

  • encoding="utf-8-sig" removes the invisible byte-order mark at the start of the files. Without it, the first column is read as something like Div.
  • Reading the header first lets us request only the columns that exist. Older seasons do not have AvgH; asking for it would raise an error.
  • dropna(subset=["FTR"]) removes empty trailing rows that some files contain.

Step 3: combine and fix the dates

paths = sorted(DATA.glob("E0_*.csv"))
matches = pd.concat([load_season(p) for p in paths], ignore_index=True)
matches["Date"] = pd.to_datetime(matches["Date"], dayfirst=True, format="mixed")
matches = matches.sort_values("Date").reset_index(drop=True)
print(len(matches), matches["Season"].nunique())

Output when we ran it:

4180 11

Watch out: Our first version used pd.to_datetime(..., dayfirst=True) without format="mixed" and crashed: one season writes dates as 13/08/16, the others as 13/08/2016. Always check date parsing on every season you load.

Step 4: sanity checks

Before modelling, check that the numbers make sense:

assert matches["FTR"].isin(["H", "D", "A"]).all()
print(matches.groupby("Season").size().unique())        # [380]
print(matches["FTR"].value_counts(normalize=True).round(3))

Every season should have 380 matches. Across 2015-16 to 2025-26, we found home wins in 44.3% of matches.

Key point: Write checks for what you know must be true: 380 matches per season, 20 teams, results that match the goals. Data errors caught here are cheap; caught after a model is built, they are expensive.

What comes next

With a clean, date-sorted table you can build ratings and forecasts. Continue with Build an Elo Rating System in Python, or try goal-based forecasting in Build a Poisson Football Model in Python.

  • Download each season once and keep local copies.
  • Use utf-8-sig, request only existing columns and parse dates with format="mixed".
  • Add assertions for facts you know, such as 380 matches per season.

Sources

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