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 likeDiv.- 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 withformat="mixed". - Add assertions for facts you know, such as 380 matches per season.
Sources
- English football results and data — Football-Data.co.uk
- pandas.read_csv — pandas documentation
18+ only. Models do not guarantee results. See our Responsible Gambling page.