Pull Live Soccer Results from ESPN's Public Scoreboard API
Part 7 of 7 in Working with Real Sports APIs · course bundle (code + data)
What you'll build
The complete 2025-26 Premier League pulled live from ESPN's keyless scoreboard JSON - all 380 matches from two year-form calls filtered to the season's own tag, after dodging the endpoint's silent 100-event cap - flattened to a tidy CSV behind a guard that refuses to overwrite a complete season with a worse refresh, plus the season read: goals per match, the home/draw/away split, and the month-by-month scoring shape.

ESPN never advertises an API, but every scoreboard page on the site is fed by one — a keyless JSON endpoint under site.api.espn.com that hands you real Premier League results for a single GET. No token, no signup, no wrapper library to install. This tutorial opens it the way you’d open any undocumented API in production: probe one day’s scoreboard to learn the shape (August 22, 2026 included Hull City 2-0 Manchester United, which is why we check data instead of assuming it), then pull the complete 2025-26 season — all 380 matches from two year-form calls, filtered to the season’s own tag. The endpoint fights back twice, and both fights are the curriculum: it silently caps any response at 100 events unless you raise the limit, and it returns 403 to requests dressed up as a browser while happily serving an honest default python-requests User-Agent — the exact opposite of what the NBA’s API demands. Both behaviors are measured, not rumored; you’ll reproduce them yourself in steps 1 and 2.
Updated September 18, 2026. This tutorial used to pull the season with a single date-range call, dates=20250801-20260601. ESPN has retired that form: it now answers 400 — “Failed to get events endpoint.” for every sport, eng.1 included, so the old code stopped dead at raise_for_status(). The single-date, month (dates=YYYYMM) and year (dates=YYYY) forms all still return 200, so step 2 now makes two year calls — dates=2025 and dates=2026 — and keeps the events whose season.slug is 2025-26-english-premier-league: 186 + 194 = 380. Nothing else moved. dates=2025 without limit= still returns exactly 100 events, so the silent-cap lesson below is the same lesson with the same number, and the season read is unchanged at 1,045 goals, 2.75 a match. The switch is left visible on purpose: an undocumented endpoint changing under a working script is the exact risk the closing section warns about, and it is more useful to watch one get handled than to be told it might happen.
You want two things on board before starting: the endpoint-and-parameters vocabulary from how to read API documentation and the groupby fluency from the aggregation tutorial. The NHL pull is this tutorial’s sibling — the same raw-requests-and-JSON dance against a friendlier server. All live outputs below were retrieved September 18, 2026; everything after the pull also runs offline from the bundled epl_results_2025_26.csv the script saves — which is exactly the file the guard in step 4 exists to protect.
-
Probe one day’s scoreboard
Never start an undocumented API with your real query. Start with the smallest request that can succeed, and read what comes back. The scoreboard endpoint takes a
dates=parameter asYYYYMMDD; the league lives in the URL path (eng.1is the Premier League). One deliberate choice here: no fake headers. On most sports APIs you’d send a browser User-Agent — stats.nba.com won’t talk to you without one. ESPN’s edge does the reverse: from this machine, the spoofed Chrome User-Agent gets403 Forbiddenand the honest default one gets200. Bot filters look for costumes. Identify yourself plainly, and save the disguises for servers that require them.python import requests BASE = "https://site.api.espn.com/apis/site/v2/sports/soccer/eng.1/scoreboard" day = requests.get(BASE, params={"dates": "20260822"}, timeout=30) day.raise_for_status() day = day.json() lg = day["leagues"][0] print(f'{lg["name"]} - {lg["season"]["displayName"]}') print(f'scoreboard for dates=20260822: {len(day["events"])} events\n') for ev in day["events"]: sides = {c["homeAway"]: c for c in ev["competitions"][0]["competitors"]} print(f' {ev["date"]} {sides["home"]["team"]["displayName"]:<24} ' f'{sides["home"]["score"]}-{sides["away"]["score"]} ' f'{sides["away"]["team"]["displayName"]:<24} ' f'({ev["status"]["type"]["detail"]})')One Saturday of the current season, straight from the JSONEnglish Premier League - 2026-27 English Premier League scoreboard for dates=20260822: 5 events 2026-08-22T11:30Z Hull City 2-0 Manchester United (FT) 2026-08-22T14:00Z Everton 2-0 Crystal Palace (FT) 2026-08-22T14:00Z Ipswich Town 2-1 Sunderland (FT) 2026-08-22T14:00Z Nottingham Forest 0-1 Leeds United (FT) 2026-08-22T16:30Z Brentford 3-0 Tottenham Hotspur (FT)
Real matches, real scores, five events on a quiet Saturday of the young 2026-27 season. The one-line dictionary trick in the loop is worth keeping: each event carries a
competitorslist whose order you shouldn’t trust, but every competitor declares itself"home"or"away"— so{c["homeAway"]: c for c in ...}turns the list into a dictionary you can index by role. And note whatraise_for_status()buys you: if ESPN ever does refuse the request, the script stops loudly on that line instead of handing mysteriousKeyErrors to the code below it. -
Pull the whole season — and meet the silent cap
The
dates=parameter widens: hand itYYYYMMand you get a month, hand itYYYYand you get a whole calendar year. (It used to accept aYYYYMMDD-YYYYMMDDrange as well; that form began returning400on September 18, 2026, which is why this step now asks for years.) A 2025-26 season straddles two of them, so the move is two calls —dates=2025anddates=2026— kept to the events tagged with this season’s own slug. The obvious version of that move is still a trap. Run it both ways and count:python capped = requests.get(BASE, params={"dates": "2025"}, timeout=60).json() y2025 = requests.get(BASE, params={"dates": "2025", "limit": 500}, timeout=60).json() y2026 = requests.get(BASE, params={"dates": "2026", "limit": 500}, timeout=60).json() SEASON_SLUG = "2025-26-english-premier-league" season = [ev for ev in y2025["events"] + y2026["events"] if ev["season"]["slug"] == SEASON_SLUG] print(f'dates=2025 -> {len(capped["events"])} events (!)') print(f'dates=2025&limit=500 -> {len(y2025["events"])} events') print(f'dates=2026&limit=500 -> {len(y2026["events"])} events') print(f'tagged {SEASON_SLUG} -> {len(season)} events')The same year, with and without limit= - then the season tagdates=2025 -> 100 events (!) dates=2025&limit=500 -> 378 events dates=2026&limit=500 -> 374 events tagged 2025-26-english-premier-league -> 380 events Same year, same HTTP 200. Without limit= the endpoint quietly returns its default page of 100 - no error, no warning field. A 380-match season pulled naively is a 100-match season. And a calendar year is not a season: those 752 events also carry 2024-25 and 2026-27 fixtures, which is what the season tag filters out.
This is the most instructive failure mode in data engineering: the one that looks like success. HTTP 200, valid JSON, a plausible list of matches — and 278 of that year’s 378 missing, because the endpoint’s default page size is 100 and nothing in the response says “truncated.” If you hadn’t known a Premier League season has 380 matches, when would you have noticed? That’s the discipline this step teaches: every pull ends by comparing the count you got against the count the world says you should have. A season has 20 teams playing 38 games; those numbers are checkable, and step 3 checks them. One etiquette note before moving on: this tutorial makes four requests total, a second apart, and then never touches the network again — the season is done, the file is saved, and re-running your analysis fifty times re-reads a local CSV instead of re-asking ESPN.
-
Flatten the JSON into one row per match
Each event nests what we want three layers deep: the kickoff time on the event, the teams and scores inside
competitions[0].competitors, the venue and attendance beside them. Wrap the unpacking in a function — not for elegance, but because step 4 is about to feed it two different pulls, and a transformation you’ll apply twice belongs in one place. Two details deserve their honesty flags: scores arrive as strings ("4", not4— JSON APIs do this constantly, and pandas will happily sort strings into"10" < "2"nonsense if you skip theint()), and we keep only events whose status sayscompleted, so a postponed fixture can never sneak a 0-0 into the dataset.python import pandas as pd def tidy(events): """Flatten ESPN scoreboard events into one row per completed match.""" rows = [] for ev in events: if not ev["status"]["type"]["completed"]: continue comp = ev["competitions"][0] sides = {c["homeAway"]: c for c in comp["competitors"]} rows.append({ "date": ev["date"][:10], "home": sides["home"]["team"]["displayName"], "away": sides["away"]["team"]["displayName"], "home_goals": int(sides["home"]["score"]), "away_goals": int(sides["away"]["score"]), "venue": comp.get("venue", {}).get("fullName", ""), "attendance": int(comp.get("attendance") or 0), }) return pd.DataFrame(rows).sort_values(["date", "home"]).reset_index(drop=True) df = tidy(season) print(len(df), df["home"].nunique(), df["date"].min(), df["date"].max()) print(sorted({ev["season"]["slug"] for ev in season})) df.head()The season, validated and tidy380 completed matches from live pull teams: 20 dates: 2025-08-15 to 2026-05-24 season tag on every event: ['2025-26-english-premier-league'] date home away home_goals away_goals venue attendance 0 2025-08-15 Liverpool AFC Bournemouth 4 2 Anfield 60315 1 2025-08-16 Aston Villa Newcastle United 0 0 Villa Park 42526 2 2025-08-16 Brighton & Hove Albion Fulham 1 1 American Express Stadium 31478 3 2025-08-16 Sunderland West Ham United 3 0 Stadium of Light 46233 4 2025-08-16 Tottenham Hotspur Burnley 3 0 Tottenham Hotspur Stadium 61077The validation trio does its job: 380 matches, 20 teams, 2025-08-15 to 2026-05-24 — opening day to final day, nothing missing, nothing extra. The last check is the subtle one: every event carries a season tag, and printing the set of tags proves the filter in step 2 did its job — a calendar year of
eng.1really does carry 2024-25 and 2026-27 fixtures, so if a single one had slipped through, this line would print two slugs instead of one. Note the fields we didn’t hard-index:venueandattendanceride through.get()with defaults, because optional extras should degrade to blanks, not crash the pull. -
The guard: never overwrite good data with a bad refresh
Here is where a script becomes a dataset you maintain. The naive save is one line,
df.to_csv(...)— and it means the next run, months from now, on hotel wifi, with a half-failed pull or a forgottenlimit=, silently replaces your complete season with a stump. The fix is a promotion rule: validate the new pull against the file already on disk, and refuse the write if it’s worse. Row count is the crudest possible fitness test and still catches the two real failure modes this API has shown us. Best of all, we don’t have to invent a bad refresh to prove the guard works — step 2’s capped pull is one:python import os def promote(new_df, path): """Write new_df to path only if it is at least as complete as the file there.""" have = len(pd.read_csv(path)) if os.path.exists(path) else 0 if len(new_df) < have: print(f"REFUSED: fresh pull has {len(new_df)} rows; " f"{os.path.basename(path)} already holds {have}. Keeping the file.") return False new_df.to_csv(path, index=False) print(f"promoted: {len(new_df)} rows -> {os.path.basename(path)} (replaced {have})") return True promote(tidy(season), "epl_results_2025_26.csv") # the real season promote(tidy(capped["events"]), "epl_results_2025_26.csv") # the capped pull from step 2The guard, tested against the failure the API actually producespromoted: 380 rows -> epl_results_2025_26.csv (replaced 380) REFUSED: fresh pull has 100 rows; epl_results_2025_26.csv already holds 380. Keeping the file.
First call promotes the full 380 rows over the equally complete 380 the previous run left on disk; second call — fed the genuinely truncated 100-event response, which is also the wrong season — is refused, and the good file survives. This validate-before-write shape is the smallest member of a family you’ll meet everywhere serious data lives: production pipelines stage a fresh table, run checks against the live one, and swap only on a pass. Ours fits in twelve lines because the check is one comparison, but the skeleton is the same, and it grows naturally — add “20 unique teams,” add “no null scores,” add a dated backup before the swap if you want an undo trail. The principle doesn’t change: the burden of proof is on the new data.
-
The season read: 2.75 goals a match, and the home edge intact
The payoff for clean plumbing is that analysis becomes short. Label each match’s result, count the three outcomes, and group goals by month:
python df["total"] = df["home_goals"] + df["away_goals"] df["result"] = "draw" df.loc[df["home_goals"] > df["away_goals"], "result"] = "home win" df.loc[df["home_goals"] < df["away_goals"], "result"] = "away win" n = len(df) print(f"goals: {df['total'].sum()} total, {df['total'].mean():.2f} per match") split = df["result"].value_counts().reindex(["home win", "draw", "away win"]) for r, c in split.items(): print(f" {r:<9} {c:>3} ({100 * c / n:.1f}%)") big = df.loc[df["total"].idxmax()] print(f"highest-scoring match: {big.home} {big.home_goals}-{big.away_goals} " f"{big.away} ({big.date})") by_month = (df.assign(month=pd.to_datetime(df["date"]).dt.to_period("M")) .groupby("month")["total"].mean()) print(f"scoring by month: {by_month.min():.2f} per match in {by_month.idxmin()} " f"to {by_month.max():.2f} in {by_month.idxmax()}")What 380 matches say, three groupbys later2025-26 Premier League, all 380 matches: goals: 1045 total, 2.75 per match home win 162 (42.6%) draw 104 (27.4%) away win 114 (30.0%) highest-scoring match: Fulham 4-5 Manchester City (2025-12-02) biggest crowd: 74,257 - Manchester United v Burnley at Old Trafford (2025-08-30) scoring by month: 2.31 per match in 2026-03 to 2.98 in 2025-11
Three findings, all checkable against the file you just built. The league averaged 2.75 goals a match (1,045 in total). Home teams won 42.6% of matches against 30.0% for away sides — the venue edge that the home-advantage capstone measures across five leagues is alive and well in this one, worth about 48 extra home wins over the season. And scoring breathes with the calendar: a high of 2.98 per match in November 2025, a floor of 2.31 in March 2026, with the season closing at 2.88 in May as relegation fights and nothing-to-lose fixtures opened up. The season’s wildest single entry: Fulham 4-5 Manchester City on December 2 — nine goals, and the away side needed every one.

Data: ESPN public scoreboard API (live pull; bundled season CSV fallback), retrieved September 2026
What this dataset is, and what it isn’t
Be clear-eyed about what you now hold. It is a complete, validated results table for one league-season — every score, venue and attendance figure as ESPN serves them — and because the endpoint is keyless, everything here reruns for La Liga (esp.1), the Bundesliga (ger.1), or MLS (usa.1) by changing one path segment. It is not a contract: this API is undocumented, ESPN owes it to nobody, and endpoints like it get reshaped or retired without notice — as the dated note at the top of this page records, one of its parameters was retired out from under this very tutorial in September 2026 — which is an argument for the guard, the saved CSV, and gentle request habits, not against using it. It is also results-only. There are no shots, no expected goals, no events inside the ninety minutes; when the question needs that depth, StatsBomb’s free event data is the honest next door. And one modeling warning: 380 matches is a season, not a universe — the month-by-month wiggles in the chart include genuine schedule effects and plain noise, and deciding which is which is a job for the significance machinery, not the eyeball.
Troubleshooting
A date range gives me 400 Failed to get events endpoint
That form is gone — retired at ESPN’s end. dates=20250801-20260601, and every other YYYYMMDD-YYYYMMDD pair, started answering 400 on September 18, 2026 across every sport on this host, not just soccer. Any guide that teaches the range form — including this one, before that date — is out of date. Ask for a year (dates=2025) or a month (dates=202508) instead, then filter the events by season.slug, which is what step 2 now does. Worth noticing: this failure was loud. raise_for_status() turned a retired parameter into an immediate HTTPError on line one instead of an empty DataFrame three steps later.
My request comes back 403 Forbidden
First suspect: you added a browser User-Agent out of habit, and this edge network treats impersonation as a bot signal — measured here, the spoofed Chrome header is exactly what earned the 403 while the default python-requests identity sailed through. Remove the costume. If you’re still blocked, slow down and wait: shared IPs (offices, VPNs, cloud notebooks) can carry a bad reputation score that has nothing to do with your code. What never helps is retrying in a tight loop — that’s how a temporary refusal becomes a durable one.
I asked for the season and got exactly 100 matches
That’s the default page size, and “exactly 100” is the tell — real seasons don’t land on round API numbers. Add limit=500 (anything comfortably above 380) and recount. The portable lesson: whenever a result count exactly equals a power-of-ten, suspect the API before you suspect the world. Ends-in-00 counts should trigger the same reflex a too-clean correlation does.
KeyError: 'venue' (or attendance is 0) on some matches
Optional fields are optional per event, not per API — lower divisions and cup fixtures omit venue objects routinely, and attendance can arrive as 0, missing, or null. That’s why tidy() reaches for them with comp.get("venue", {}).get("fullName", "") instead of square brackets: the essentials (teams, scores, status) fail loudly, the extras degrade to blanks. If you extend the parser to new fields, decide for each one which of those two behaviors it deserves before you write the line.
My dates look shifted by a day from the fixtures I remember
Event timestamps are UTC — that trailing Z in 2025-08-15T19:00Z is doing real work — and our ev["date"][:10] keeps the UTC calendar date. For English football that matches the local fixture date (evening kickoffs in summer are 19:00Z, not past midnight), but pull a league many time zones away — an MLS west-coast night match is early the next morning in UTC — and the naive slice starts mislabeling match days. The fix when it matters: parse the full timestamp with pd.to_datetime(..., utc=True) and convert with .dt.tz_convert() to the league’s zone before taking the date.
Challenge yourself
Three extensions, each one production habit deeper. First, breadth: rerun the pull for La Liga (esp.1) over the same two years, and put both leagues’ goals-per-match and home-win rates side by side — one function argument should be all that changes, which is the test of whether your code is really a tool yet. Second, the daily delta: write a script that pulls only yesterday’s dates=, appends any completed matches to the CSV, and drops duplicates on (date, home, away) — run it every morning during the 2026-27 season and you own a live dataset that no single failed pull can ruin, because the guard idea now applies per-append. Third, the payoff loop: feed your finished CSV to the standings-table builder and check the top four against the real table, then hand the same file to the Poisson scoreline model — 380 matches of one league is exactly the data that model wanted and the World Cup couldn’t give it.
Get the code
Want it all in one file? This is the finished script behind this tutorial - the run that produced the outputs above.
Download the finished script (89_pull_live_soccer_results_from_espn.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, epl_results_2025_26.csv. Or skip the collecting: the Working with Real Sports APIs bundle has this whole course’s scripts and data in one ZIP.


