ESPN cross-league architecture
sportsdataverse-py wraps 126 short names across 40 leagues of ESPN endpoints
(NBA, MBB, WNBA, WBB, CFB, NFL, MLB, NHL, plus college hockey, baseball and softball,
spring football, soccer, cricket and international basketball — NHL also has its own modern
api-web.nhle.com path; see the NHL section) from a
single set of endpoint specs parameterized by the {sport}/{league}
slugs. This page explains why that's possible and how the wrappers are
generated. For the bigger picture — naming conventions, the R sister
packages, and how this fits the wider ecosystem — see
Ecosystem & philosophy.
The observation that powers everything
Every ESPN API path follows the same template across sports — only the
{sport} and {league} slugs change:
https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard
https://sports.core.api.espn.com/v2/sports/{sport}/leagues/{league}/seasons/{year}
https://site.web.api.espn.com/apis/common/v3/sports/{sport}/{league}/athletes/{athleteId}/overview
| API surface | Base | Wrappers per league |
|---|---|---|
| Site v2 | site.api.espn.com/apis/site/v2/... | 23 |
| Site v2 alt | site.api.espn.com/apis/v2/... | 1 (standings) |
| Web v3 | site.web.api.espn.com/apis/common/v3/... | 5 (athlete deep dives + leaders) |
| Core v2 | sports.core.api.espn.com/v2/... | 82 |
| FITT v3 | site.web.api.espn.com/apis/fitt/v3/... | 1 (fpi) |
| Total universal | 112 wrappers per league | |
| NCAA-only extras | (6 wrappers — rankings + recruiting) | enabled for mbb, wbb, cfb, mch, wch, college_baseball, college_softball |
| Football-only extras | (2 wrappers — QBR) | enabled for nfl, cfb |
| MLB-only extras | (1 wrapper — athlete_hotzones) | enabled for mlb |
| CDN extras | (up to 5 — cdn_*) | per-endpoint include_prefixes allowlist in espn_cdn.yaml |
A few leagues drop an endpoint ESPN does not serve for them (exclude_leagues), so a
league's count can sit one below the sum.
The implementation: declarative codegen
Earlier versions registered the wrappers at import time with a runtime
factory (make_league_module() + functools.partial). That has been
retired in favor of declarative code generation: the wrappers are now
plain, concrete functions written to disk, so they are trivially
greppable, IDE-introspectable, and diff-reviewable.
The endpoint catalog lives as YAML under tools/codegen/endpoints/. One
spec per ESPN API surface describes each endpoint once — its path, params,
parser, and example — using the {sport}/{league} template:
# tools/codegen/endpoints/espn_site_v2.yaml (excerpt)
- short: scoreboard
path: /{sport}/{league}/scoreboard
parser: parse_scoreboard
returns_schema: scoreboard
python tools/codegen/generate.py renders those specs into one concrete
module per league — sportsdataverse/<league>/<league>_espn_ext.py —
substituting the slugs and applying the naming conventions (below):
Wrappers default to return_parsed=True and hand back a tidy polars DataFrame;
pass return_parsed=False for the raw Dict, or return_as_pandas=True for pandas.
# sportsdataverse/nba/nba_espn_ext.py — GENERATED, do not edit
def espn_nba_scoreboard(dates=None, ..., *, return_parsed=True,
return_as_pandas=False, **kwargs):
raw = _get("https://site.api.espn.com/.../basketball/nba/scoreboard",
params={...}, **kwargs)
if return_parsed:
return parse_scoreboard(raw, return_as_pandas=return_as_pandas)
return raw
Result: from sportsdataverse.nba import espn_nba_scoreboard works, IDE
auto-complete lists every wrapper, and help() / inspect.signature()
show real signatures. A --check drift gate (run in CI and as a
pre-commit hook) fails if the committed modules fall out of sync with the
YAML — and the same generator emits these very reference docs via
generate.py --docs. See the
codegen toolchain notes in CLAUDE.md
for the full workflow.
Wrappers whose endpoint has a registered parser additionally take two
optional kwargs (return_parsed / return_as_pandas), described next.
The return_parsed shim
Every wrapper with a registered parser defaults to returning a polars
DataFrame (0.0.54+). Pass return_parsed=False to recover the raw
Dict, or return_as_pandas=True to get a pandas DataFrame:
from sportsdataverse.nba import espn_nba_teams_site, espn_nba_scoreboard
# Default (0.0.54+): polars DataFrame
df = espn_nba_teams_site() # → polars DataFrame
print(df.select(["team_id", "team_abbreviation", "team_display_name"]).head())
# Opt-out: raw Dict
raw = espn_nba_teams_site(return_parsed=False) # → Dict
print(raw["sports"][0]["leagues"][0]["teams"][0]["team"]["displayName"])
# pandas DataFrame
pdf = espn_nba_teams_site(return_as_pandas=True)
The two parsing kwargs (return_parsed / return_as_pandas) are additive.
Callers from 0.0.50 and earlier that relied on the raw-Dict default should
add return_parsed=False to preserve their existing behavior.
Parsing a raw payload yourself
Every ESPN short name is registered in ENDPOINT_PARSERS, so every generated
wrapper takes return_parsed. Pass return_parsed=False to get the raw Dict,
then hand it to any parser yourself:
from sportsdataverse._common_espn_parsers import parse_items
from sportsdataverse.nba import espn_nba_venues
raw = espn_nba_venues(limit=10, return_parsed=False)
df = parse_items(raw) # works on any {items: [...]} payload
Function-name discoverability
Each wrapper is a concrete, generated function, so IDE auto-complete,
help(), and inspect.signature() behave like any hand-written function:
>>> from sportsdataverse.nba import espn_nba_player_overview
>>> espn_nba_player_overview.__name__
'espn_nba_player_overview'
>>> help(espn_nba_player_overview)
# The generated docstring: endpoint URL, args, return type, example.
Note the name: ESPN's raw athletes/{id}/overview endpoint surfaces as
espn_nba_player_overview, not ..._athlete_overview — see the naming
conventions below.
Naming conventions
The generator aligns ESPN's raw taxonomy to the cfbfastR/hoopR/wehoop vocabulary, applied to every league:
- Token renames:
athlete → player,event → game(with plurals), soathletes/{id}→espn_<league>_player_info,events→..._games. - Combined renames: an
event_competitoris a game's team (event_competitor* → game_team*);event_competition → game_competition. - Collision resolution: when a rename would clash, one endpoint keeps
the bare name and the other is version-qualified — so every league has a
bare
espn_<league>_player_stats()(season stats) plus a comprehensiveespn_<league>_player_stats_v3().
Per-league function counts
126 ESPN short names, expanded across the 40 leagues. The eight major leagues,
as generate._espn_league_views resolves them today:
| League | espn_* wrappers |
|---|---|
| CFB | 125 |
| MBB | 122 |
| WBB | 121 |
| NFL | 118 |
| MLB | 117 |
| NBA | 116 |
| WNBA | 115 |
| NHL | 113 |
Counts move whenever an endpoint YAML gains or loses a league, so treat the table as a snapshot and the per-league Data sources table on each league page as the live figure.
See also
- Ecosystem & philosophy — the design philosophy, the full naming paradigm, and the R/Python/Node sister packages.
- The parser layer — how
ENDPOINT_PARSERSis built, what each parser does, and how to extend the registry. - NHL section — NHL gets its own modern
api-web.nhle.com/v1/surface plus EDGE Statcast, Stats REST, and Records modules. - MLB section — MLB pairs the ESPN cross-league wrappers with the official MLB Stats API and Baseball Savant (Statcast) wrappers.