π College football with sportsdataverse-py
Saturdays in autumn, condensed into tidy DataFrames. π In a few lines of Python you're about to pull a decade of play-by-play, full rosters, schedules, team info, plus live ESPN scoreboards, standings, polls and recruiting boards β all as clean polars frames ready to model.
CFB has no single native premium API, so our premium path is two-pronged:
- ποΈ Release loaders (
load_cfb_*) β pre-built, EPA/WPA-enriched datasets served straight from the cfbfastR-data GitHub release. Fast, reliable, no key needed. - π‘ ESPN families (
espn_cfb_*) β live scoreboards, team pages, standings, rankings, recruiting and per-play participants.
R user? Every verb here has a twin in cfbfastR. Let's kick off! π
π§° The toolboxβ
Everything returns a tidy polars DataFrame by default β pass
return_as_pandas=True for pandas, or (on the espn_cfb_* wrappers)
return_parsed=False for the raw JSON. β marks the premium path.
| Function | What it gives you | Source |
|---|---|---|
load_cfb_pbp | Full play-by-play with EPA/WPA, since 2003 | β release |
load_cfb_rosters | Season rosters (bio, position, hometown) | β release |
load_cfb_schedule | Season schedule + results + Elo | β release |
load_cfb_team_info | Team metadata: conference, colors, venue | β release |
load_cfb_betting_lines | Historical betting market lines (spread/total/ML) | β release |
espn_cfb_scoreboard | Live + recent scoreboard for a date/week | β ESPN |
espn_cfb_schedule | ESPN schedule frame for a date/week | β ESPN |
espn_cfb_teams | Every FBS/FCS team (grab team_ids) | β ESPN |
espn_cfb_team_roster | One team's roster | β ESPN |
espn_cfb_team_schedule | One team's schedule | β ESPN |
espn_cfb_standings | Conference / division standings | β ESPN |
espn_cfb_rankings | AP / Coaches / CFP polls | β ESPN |
espn_cfb_leaders | League stat leaders by category | β ESPN |
espn_cfb_recruits | Season recruiting class | β ESPN |
espn_cfb_play_participants | Per-play athletes (passer/rusher/tacklerβ¦) | β ESPN |
CFBPlayProcess | Full ESPN PBP pipeline (EPA/WPA + box) | β ESPN |
most_recent_cfb_season | The current season year helper | helper |
π Setupβ
pip install sportsdataverse
No API key required. The load_cfb_* loaders read public parquet from the
cfbfastR-data release, and the espn_cfb_* wrappers hit ESPN's public
endpoints.
import polars as pl
import sportsdataverse as sdv
from sportsdataverse.cfb import most_recent_cfb_season
SEASON = most_recent_cfb_season()
print('most recent CFB season:', SEASON)
most recent CFB season: 2025
ESPN's live endpoints are seasonal and occasionally rate-limited, so a tiny
safe() helper runs the riskier calls defensively β you get the frame when
the feed is up, and a friendly one-liner when it isn't (never a scary
traceback). The release loaders are reliable, so we call those directly. π
def safe(label, thunk):
try:
out = thunk()
print(f'β
{label}')
return out
except Exception as e: # noqa: BLE001 -- demo resilience
print(f'βοΈ {label}: unavailable right now ({type(e).__name__})')
return None
ποΈ Premium loaders: a whole season in one callβ
The load_cfb_* family is the fastest way to get clean, complete
season data. Each takes a seasons= int or list (β₯ 2003) and returns one
tidy frame. Let's start with the schedule β one row per game, with final
scores, conference flags, and pre/post-game Elo ratings baked in.
| Function | Grain | Highlights |
|---|---|---|
load_cfb_schedule | one row / game | scores, Elo, neutral-site & conference flags |
schedule = sdv.cfb.load_cfb_schedule(seasons=[2023])
print('schedule shape:', schedule.shape)
schedule.select([
'game_id', 'week', 'home_team', 'away_team',
'home_points', 'away_points', 'home_conference', 'neutral_site',
]).head()
schedule shape: (3734, 31)
shape: (5, 8)
βββββββββββββ¬βββββββ¬ββββββββββββββ¬ββββββββββββββ¬βββββββββββββ¬βββββββββββββ¬βββββββββββββ¬βββββββββββββ
β game_id β week β home_team β away_team β home_point β away_point β home_confe β neutral_si β
β --- β --- β --- β --- β s β s β rence β te β
β i32 β i32 β str β str β --- β --- β --- β --- β
β β β β β i32 β i32 β str β bool β
βββββββββββββͺβββββββͺββββββββββββββͺββββββββββββββͺβββββββββββββͺβββββββββββββͺβββββββββββββͺβββββββββββββ‘
β 401525434 β 1 β Notre Dame β Navy β 42 β 3 β FBS Indepe β true β
β β β β β β β ndents β β
β 401540199 β 1 β Mercer β North β 17 β 7 β Southern β true β
β β β β Alabama β β β β β
β 401520145 β 1 β Jacksonvill β UTEP β 17 β 14 β Conference β false β
β β β e State β β β β USA β β
β 401532392 β 1 β San Diego β Ohio β 20 β 13 β Mountain β false β
β β β State β β β β West β β
β 401540628 β 1 β UAlbany β Fordham β 34 β 13 β CAA β false β
βββββββββββββ΄βββββββ΄ββββββββββββββ΄ββββββββββββββ΄βββββββββββββ΄βββββββββββββ΄βββββββββββββ΄βββββββββββββ
π₯ Premium loaders: rostersβ
load_cfb_rosters gives you
every listed player for a season β name, position, jersey, physicals and
hometown. Perfect for joining onto play-by-play or building depth tables.
rosters = sdv.cfb.load_cfb_rosters(seasons=[2023])
print('rosters shape:', rosters.shape)
rosters.select([
'athlete_id', 'first_name', 'last_name', 'team',
'position', 'jersey', 'home_state',
]).head()
rosters shape: (22467, 18)
shape: (5, 7)
ββββββββββββββ¬βββββββββββββ¬ββββββββββββ¬ββββββββββββββββββββ¬βββββββββββ¬βββββββββ¬βββββββββββββ
β athlete_id β first_name β last_name β team β position β jersey β home_state β
β --- β --- β --- β --- β --- β --- β --- β
β str β str β str β str β str β i32 β str β
ββββββββββββββͺβββββββββββββͺββββββββββββͺββββββββββββββββββββͺβββββββββββͺβββββββββͺβββββββββββββ‘
β 102597 β Will β Rogers β Mississippi State β QB β 7 β MS β
β 107494 β Trey β Sanders β TCU β RB β 2 β FL β
β 146583 β John β Adams β Temple β WR β 17 β NJ β
β 160900 β Will β Johnson β Michigan β null β null β null β
β 169499 β Ryan β Johnson β Akron β DL β 4 β MS β
ββββββββββββββ΄βββββββββββββ΄ββββββββββββ΄ββββββββββββββββββββ΄βββββββββββ΄βββββββββ΄βββββββββββββ
ποΈ Premium loaders: team infoβ
load_cfb_team_info
carries the reference metadata you'll want to label every chart: school
name, conference, classification (FBS/FCS), team colors, and venue.
team_info = sdv.cfb.load_cfb_team_info(seasons=[2023])
print('team_info shape:', team_info.shape)
team_info.select([
'team_id', 'school', 'conference', 'classification',
'venue_name', 'city', 'state', 'dome',
]).head()
team_info shape: (1840, 28)
shape: (5, 8)
βββββββββββ¬ββββββββββββββββ¬ββββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ¬βββββββββββ¬ββββββββ¬ββββββββ
β team_id β school β conference β classificati β venue_name β city β state β dome β
β --- β --- β --- β on β --- β --- β --- β --- β
β i32 β str β str β --- β str β str β str β bool β
β β β β str β β β β β
βββββββββββͺββββββββββββββββͺββββββββββββββββͺβββββββββββββββͺβββββββββββββββͺβββββββββββͺββββββββͺββββββββ‘
β 2000 β Abilene β UAC β fcs β Wildcat β Abilene β TX β false β
β β Christian β β β Stadium (TX) β β β β
β 2001 β Adams State β Rocky β ii β Rex Stadium β Alamosa β CO β false β
β β β Mountain β β β β β β
β 2003 β Adrian β Michigan β iii β Docking β Adrian β MI β false β
β β β β β Stadium β β β β
β 2005 β Air Force β Mountain West β fbs β Falcon β Colorado β CO β false β
β β β β β Stadium β Springs β β β
β 2006 β Akron β Mid-American β fbs β Summa Field β Akron β OH β false β
β β β β β at β β β β
β β β β β InfoCision β β β β
β β β β β Stadβ¦ β β β β
βββββββββββ΄ββββββββββββββββ΄ββββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ΄βββββββββββ΄ββββββββ΄ββββββββ
π¬ Premium loaders: play-by-play with EPAβ
The crown jewel. load_cfb_pbp
returns every play of a season with hundreds of engineered columns β
down & distance, win probability, and Expected Points Added (EPA)
already computed. (It's a big pull, so we grab a single season and peek.) π
# The release serves PBP for whichever seasons are currently published.
# Try a few recent-ish seasons and keep the first one that comes back full,
# so the EPA recipes below always have real plays to chew on.
pbp = pl.DataFrame()
for yr in (2023, 2022, 2021, 2020):
cand = safe(f'load_cfb_pbp {yr}', lambda yr=yr: sdv.cfb.load_cfb_pbp(seasons=[yr]))
if cand is not None and cand.width > 0 and cand.height > 0:
pbp, PBP_SEASON = cand, yr
break
else:
PBP_SEASON = None
print('pbp season:', PBP_SEASON, '| pbp shape:', pbp.shape)
cols = ['game_id', 'start.pos_team.name', 'down', 'distance',
'play_type', 'EPA', 'wpa']
have = [c for c in cols if c in pbp.columns]
pbp.select(have).head() if have else 'pbp not published for these seasons right now'
β
load_cfb_pbp 2023
pbp season: 2023 | pbp shape: (153625, 460)
shape: (5, 6)
βββββββββββββ¬ββββββββββββββββββββββ¬βββββββ¬βββββββββββ¬ββββββββββββ¬ββββββββββββ
β game_id β start.pos_team.name β down β distance β EPA β wpa β
β --- β --- β --- β --- β --- β --- β
β i64 β str β i64 β i64 β f64 β f64 β
βββββββββββββͺββββββββββββββββββββββͺβββββββͺβββββββββββͺββββββββββββͺββββββββββββ‘
β 401523986 β San JosΓ© State β 1 β 10 β 0.0 β 0.0 β
β 401523986 β San JosΓ© State β 1 β 10 β -0.687781 β -0.003872 β
β 401523986 β San JosΓ© State β 2 β 10 β 2.339749 β 0.019609 β
β 401523986 β San JosΓ© State β 1 β 10 β 0.121639 β 0.002179 β
β 401523986 β San JosΓ© State β 2 β 5 β -1.149794 β -0.006335 β
βββββββββββββ΄ββββββββββββββββββββββ΄βββββββ΄βββββββββββ΄ββββββββββββ΄ββββββββββββ
π‘ Live from ESPN: the scoreboardβ
When you need today's slate (or a specific date), the ESPN wrappers shine.
espn_cfb_scoreboard takes a
dates=YYYYMMDD (or season year) and returns the games on the board. We wrap
it in safe() since live endpoints can be quiet in the offseason.
board = safe(
'ESPN scoreboard',
lambda: sdv.cfb.espn_cfb_scoreboard(dates=20231125), # rivalry Saturday
)
if board is not None and getattr(board, 'height', 0):
keep = [c for c in board.columns
if c in ('game_id', 'name', 'short_name', 'status_type_description',
'home_team_abbreviation', 'away_team_abbreviation')]
out = board.select(keep).head() if keep else board.head()
else:
out = 'no games on the board for that date'
out
β
ESPN scoreboard
shape: (5, 4)
βββββββββββββ¬ββββββββββββββββββββββββββββββββββ¬βββββββββββββ¬ββββββββββββββββββββββββββ
β game_id β name β short_name β status_type_description β
β --- β --- β --- β --- β
β str β str β str β str β
βββββββββββββͺββββββββββββββββββββββββββββββββββͺβββββββββββββͺββββββββββββββββββββββββββ‘
β 401520430 β Georgia Bulldogs at Georgia Teβ¦ β UGA @ GT β Final β
β 401520434 β Ohio State Buckeyes at Michigaβ¦ β OSU @ MICH β Final β
β 401524068 β Washington State Cougars at Waβ¦ β WSU @ WASH β Final β
β 401520429 β Florida State Seminoles at Floβ¦ β FSU @ FLA β Final β
β 401520427 β Alabama Crimson Tide at Auburnβ¦ β ALA @ AUB β Final β
βββββββββββββ΄ββββββββββββββββββββββββββββββββββ΄βββββββββββββ΄ββββββββββββββββββββββββββ
π« Live from ESPN: teams (and their team_ids)β
espn_cfb_teams lists every
team in a division (groups=80 FBS, groups=81 FCS). The team_id column
is the key you feed into every team-scoped ESPN call below.
teams = safe('ESPN teams', sdv.cfb.espn_cfb_teams)
if teams is not None and teams.height:
cols = [c for c in ('team_id', 'team_location', 'team_name',
'team_abbreviation') if c in teams.columns]
out = teams.select(cols).head(8)
else:
out = 'teams unavailable right now'
out
β
ESPN teams
shape: (8, 4)
βββββββββββ¬ββββββββββββββββββββ¬βββββββββββββββ¬ββββββββββββββββββββ
β team_id β team_location β team_name β team_abbreviation β
β --- β --- β --- β --- β
β str β str β str β str β
βββββββββββͺββββββββββββββββββββͺβββββββββββββββͺββββββββββββββββββββ‘
β 2000 β Abilene Christian β Wildcats β ACU β
β 2001 β Adams State β Grizzlies β ADSU β
β 2003 β Adrian β Bulldogs β ADR β
β 2005 β Air Force β Falcons β AF β
β 2006 β Akron β Zips β AKR β
β 2010 β Alabama A&M β Bulldogs β AAMU β
β 333 β Alabama β Crimson Tide β ALA β
β 2011 β Alabama State β Hornets β ALST β
βββββββββββ΄ββββββββββββββββββββ΄βββββββββββββββ΄ββββββββββββββββββββ
π³ Cookbook: common CFB tasksβ
Now the fun part β real questions, answered with a few expressions. The loaders are reliable so these recipes lean on them, reaching for ESPN where it adds something live.
Recipe 1 β Highest-scoring games of the season π₯β
Straight from the loaded schedule: add the two scores and sort. No casting needed β the release frame already stores points as integers.
(schedule
.with_columns(
(pl.col('home_points') + pl.col('away_points')).alias('total_points')
)
.sort('total_points', descending=True)
.select(['week', 'home_team', 'away_team',
'home_points', 'away_points', 'total_points'])
.head(10))
shape: (10, 6)
ββββββββ¬βββββββββββββββββββββββ¬βββββββββββββββββββββ¬ββββββββββββββ¬ββββββββββββββ¬βββββββββββββββ
β week β home_team β away_team β home_points β away_points β total_points β
β --- β --- β --- β --- β --- β --- β
β i32 β str β str β i32 β i32 β i32 β
ββββββββͺβββββββββββββββββββββββͺβββββββββββββββββββββͺββββββββββββββͺββββββββββββββͺβββββββββββββββ‘
β 9 β Colby College β Middlebury β null β null β null β
β 9 β Bowdoin β Trinity (CT) β null β null β null β
β 9 β Bates β Williams β null β null β null β
β 11 β Worcester St β Framingham State β null β null β null β
β 10 β Defiance College β Rose-Hulman β 54 β 78 β 132 β
β 10 β Muskingum University β Wilmington (OH) β 64 β 63 β 127 β
β 2 β Coast Guard β Anna Maria College β 93 β 24 β 117 β
β 13 β Oklahoma β TCU β 69 β 45 β 114 β
β 3 β Texas State β Jackson State β 77 β 34 β 111 β
β 10 β Cornell College (IA) β Illinois College β 34 β 76 β 110 β
ββββββββ΄βββββββββββββββββββββββ΄βββββββββββββββββββββ΄ββββββββββββββ΄ββββββββββββββ΄βββββββββββββββ
Recipe 2 β Team offensive EPA/play leaderboard πβ
This is what premium EPA-tagged play-by-play unlocks. Filter to real scrimmage plays, group by the offense, and average the EPA per play β a clean efficiency ranking in five lines.
team_col = 'start.pos_team.name' # human-readable offense on each play
epa_cols = {team_col, 'EPA', 'play'}
if epa_cols.issubset(pbp.columns):
leaderboard = (
pbp
.filter(pl.col('play') & pl.col('EPA').is_not_null())
.group_by(team_col)
.agg(
pl.len().alias('plays'),
pl.col('EPA').mean().round(3).alias('epa_per_play'),
)
.filter(pl.col('plays') >= 500)
.sort('epa_per_play', descending=True)
.rename({team_col: 'offense'})
.head(15)
)
out = leaderboard
else:
out = 'expected EPA columns not present in this pbp build'
out
shape: (15, 3)
ββββββββββββββ¬ββββββββ¬βββββββββββββββ
β offense β plays β epa_per_play β
β --- β --- β --- β
β str β u32 β f64 β
ββββββββββββββͺββββββββͺβββββββββββββββ‘
β LSU β 945 β 0.374 β
β Oregon β 1052 β 0.343 β
β Georgia β 1064 β 0.268 β
β USC β 956 β 0.213 β
β Liberty β 1079 β 0.205 β
β β¦ β β¦ β β¦ β
β Missouri β 1003 β 0.153 β
β Alabama β 1030 β 0.153 β
β Ohio State β 931 β 0.151 β
β Miami β 1005 β 0.148 β
β Ole Miss β 989 β 0.147 β
ββββββββββββββ΄ββββββββ΄βββββββββββββββ
Recipe 3 β A team's roster, sorted by position π§©β
Join the loaded roster against team_info to resolve a school name to its
players, then count the depth at each position group.
team_name = 'Michigan'
squad = (
rosters
.filter(pl.col('team') == team_name)
.select(['first_name', 'last_name', 'position', 'jersey',
'height', 'weight', 'home_state'])
)
if squad.height:
depth = (squad.group_by('position')
.agg(pl.len().alias('players'))
.sort('players', descending=True))
print(f'{team_name}: {squad.height} players')
out = depth.head(10)
else:
out = f'no roster rows for {team_name} (try another school string)'
out
Michigan: 144 players
shape: (10, 2)
ββββββββββββ¬ββββββββββ
β position β players β
β --- β --- β
β str β u32 β
ββββββββββββͺββββββββββ‘
β DB β 23 β
β OL β 21 β
β WR β 19 β
β LB β 18 β
β DL β 12 β
β DE β 12 β
β TE β 11 β
β RB β 11 β
β PK β 6 β
β QB β 6 β
ββββββββββββ΄ββββββββββ
Recipe 4 β Who was on the field? Per-play participants π΅οΈβ
espn_cfb_play_participants
resolves the athletes involved in each play (passer, rusher, receiver,
tacklerβ¦) straight from ESPN's authoritative participants[] array β far
more reliable than regex-parsing the play text. Set resolve_missing=False
to skip the per-athlete $ref fan-out and keep it snappy.
gid = 401628334 # 2024 CFP National Championship
participants = safe(
f'play participants {gid}',
lambda: sdv.cfb.espn_cfb_play_participants(
game_id=gid, resolve_missing=False,
),
)
if participants is not None and getattr(participants, 'height', 0):
name_cols = [c for c in participants.columns if c.endswith('_player_name')]
show = ['play_id'] + name_cols[:4] if 'play_id' in participants.columns else name_cols[:5]
out = participants.select([c for c in show if c in participants.columns]).head()
else:
out = 'participants feed quiet right now (offseason / rate limit)'
out
β
play participants 401628334
shape: (5, 5)
βββββββββββββββββββββ¬ββββββββββββββββββββ¬ββββββββββββββββββββ¬ββββββββββββββββββββ¬βββββββββββββββββββ
β play_id β kicker_player_nam β returner_player_n β passer_player_nam β receiver_player_ β
β --- β e β ame β e β name β
β i64 β --- β --- β --- β --- β
β β str β str β str β str β
βββββββββββββββββββββͺββββββββββββββββββββͺββββββββββββββββββββͺββββββββββββββββββββͺβββββββββββββββββββ‘
β 40162833410184990 β Michael Lantz β Zavion Thomas β null β null β
β 2 β β β β β
β 40162833410185440 β null β null β Garrett Nussmeier β Kyren Lacy β
β 1 β β β β β
β 40162833410185750 β null β null β Garrett Nussmeier β Kyren Lacy β
β 1 β β β β β
β 40162833410185960 β null β null β null β null β
β 1 β β β β β
β 40162833410186700 β null β null β Garrett Nussmeier β CJ Daniels β
β 1 β β β β β
βββββββββββββββββββββ΄ββββββββββββββββββββ΄ββββββββββββββββββββ΄ββββββββββββββββββββ΄βββββββββββββββββββ
Recipe 5 β Build a standings table from the schedule πβ
No standings endpoint needed: stack each team's home and away results, count wins and losses, and you've got a win-percentage table for any season the loader serves.
completed = schedule.filter(pl.col('completed') == True)
home = completed.select(
pl.col('home_team').alias('team'),
(pl.col('home_points') > pl.col('away_points')).alias('win'),
)
away = completed.select(
pl.col('away_team').alias('team'),
(pl.col('away_points') > pl.col('home_points')).alias('win'),
)
standings_tbl = (
pl.concat([home, away])
.group_by('team')
.agg(
pl.col('win').sum().alias('wins'),
(~pl.col('win')).sum().alias('losses'),
)
.with_columns(
(pl.col('wins') / (pl.col('wins') + pl.col('losses')))
.round(3).alias('win_pct')
)
.sort(['wins', 'win_pct'], descending=True)
)
standings_tbl.head(10)
shape: (10, 4)
ββββββββββββββββββββββββββββ¬βββββββ¬βββββββββ¬ββββββββββ
β team β wins β losses β win_pct β
β --- β --- β --- β --- β
β str β u32 β u32 β f64 β
ββββββββββββββββββββββββββββͺβββββββͺβββββββββͺββββββββββ‘
β Michigan β 15 β 0 β 1.0 β
β Harding University β 15 β 0 β 1.0 β
β South Dakota State β 15 β 0 β 1.0 β
β Washington β 14 β 1 β 0.933 β
β Cortland β 14 β 1 β 0.933 β
β Colorado School Of Mines β 14 β 1 β 0.933 β
β North Central College β 14 β 1 β 0.933 β
β Florida State β 13 β 1 β 0.929 β
β Georgia β 13 β 1 β 0.929 β
β Wartburg β 13 β 1 β 0.929 β
ββββββββββββββββββββββββββββ΄βββββββ΄βββββββββ΄ββββββββββ
Recipe 6 β End-of-season Elo power ratings β‘β
Every schedule row ships pre- and post-game Elo ratings. Grab each team's most recent post-game Elo (sort by week, take the first) for a tidy, ready-to-rank power table β no model to fit.
elo = (
pl.concat([
schedule.select(
pl.col('home_team').alias('team'),
pl.col('week'),
pl.col('home_postgame_elo').alias('elo'),
),
schedule.select(
pl.col('away_team').alias('team'),
pl.col('week'),
pl.col('away_postgame_elo').alias('elo'),
),
])
.filter(pl.col('elo').is_not_null())
.sort('week', descending=True)
.group_by('team', maintain_order=True)
.agg(pl.first('elo').alias('final_elo'))
.sort('final_elo', descending=True)
)
elo.head(15)
shape: (15, 2)
βββββββββββββββββ¬ββββββββββββ
β team β final_elo β
β --- β --- β
β str β i32 β
βββββββββββββββββͺββββββββββββ‘
β Michigan β 2174 β
β Georgia β 2111 β
β Ohio State β 2108 β
β Penn State β 2061 β
β Texas β 2050 β
β β¦ β β¦ β
β Florida State β 1951 β
β Kansas State β 1942 β
β Washington β 1883 β
β SMU β 1861 β
β James Madison β 1835 β
βββββββββββββββββ΄ββββββββββββ
Recipe 7 β One team's full game log πβ
Filter the schedule to a single program, then flip the home/away columns so every row reads from that team's perspective β opponent, points for, points against, and the margin. Swap team to scout anyone.
team = 'Michigan'
gamelog = (
schedule
.filter((pl.col('home_team') == team) | (pl.col('away_team') == team))
.unique(subset=['game_id'])
.with_columns(
pl.when(pl.col('home_team') == team)
.then(pl.col('away_team')).otherwise(pl.col('home_team'))
.alias('opponent'),
pl.when(pl.col('home_team') == team)
.then(pl.col('home_points')).otherwise(pl.col('away_points'))
.alias('pts_for'),
pl.when(pl.col('home_team') == team)
.then(pl.col('away_points')).otherwise(pl.col('home_points'))
.alias('pts_against'),
)
.with_columns(
(pl.col('pts_for') - pl.col('pts_against')).alias('margin')
)
.select(['week', 'opponent', 'pts_for', 'pts_against', 'margin',
'neutral_site'])
.sort('week')
)
gamelog.head(16) if gamelog.height else f'no games found for {team}'
shape: (15, 6)
ββββββββ¬ββββββββββββββββ¬ββββββββββ¬ββββββββββββββ¬βββββββββ¬βββββββββββββββ
β week β opponent β pts_for β pts_against β margin β neutral_site β
β --- β --- β --- β --- β --- β --- β
β i32 β str β i32 β i32 β i32 β bool β
ββββββββͺββββββββββββββββͺββββββββββͺββββββββββββββͺβββββββββͺβββββββββββββββ‘
β 1 β East Carolina β 30 β 3 β 27 β false β
β 1 β Alabama β 27 β 20 β 7 β true β
β 1 β Washington β 34 β 13 β 21 β true β
β 2 β UNLV β 35 β 7 β 28 β false β
β 3 β Bowling Green β 31 β 6 β 25 β false β
β β¦ β β¦ β β¦ β β¦ β β¦ β β¦ β
β 10 β Purdue β 41 β 13 β 28 β false β
β 11 β Penn State β 24 β 15 β 9 β false β
β 12 β Maryland β 31 β 24 β 7 β false β
β 13 β Ohio State β 30 β 24 β 6 β false β
β 14 β Iowa β 26 β 0 β 26 β true β
ββββββββ΄ββββββββββββββββ΄ββββββββββ΄ββββββββββββββ΄βββββββββ΄βββββββββββββββ
Recipe 8 β Rushing leaders, EPA included πβ
Premium play-by-play means leaderboards aren't just totals β they carry efficiency. Filter to designed runs, sum the yards, and average the EPA per carry to separate the bell-cows from the truly explosive backs.
rush_cols = {'rush', 'rusher_player_name', 'statYardage', 'EPA'}
if rush_cols.issubset(pbp.columns):
rushers = (
pbp
.filter((pl.col('rush') == True)
& pl.col('rusher_player_name').is_not_null())
.group_by('rusher_player_name')
.agg(
pl.len().alias('carries'),
pl.col('statYardage').sum().alias('rush_yds'),
pl.col('EPA').mean().round(3).alias('epa_per_rush'),
)
.filter(pl.col('carries') >= 100)
.sort('rush_yds', descending=True)
.head(15)
)
out = rushers
else:
out = 'rushing columns not present in this pbp build'
out
shape: (15, 4)
ββββββββββββββββββββββ¬ββββββββββ¬βββββββββββ¬βββββββββββββββ
β rusher_player_name β carries β rush_yds β epa_per_rush β
β --- β --- β --- β --- β
β str β u32 β i64 β f64 β
ββββββββββββββββββββββͺββββββββββͺβββββββββββͺβββββββββββββββ‘
β Ollie Gordon II β 280 β 1762 β 0.076 β
β Kimani Vidal β 288 β 1600 β 0.044 β
β Cody Schrader β 274 β 1585 β 0.14 β
β Tahj Brooks β 289 β 1570 β 0.196 β
β Omarion Hampton β 243 β 1503 β 0.121 β
β β¦ β β¦ β β¦ β β¦ β
β Makhi Hughes β 255 β 1365 β 0.073 β
β Ismail Mahdi β 217 β 1362 β 0.139 β
β Jaydn Ott β 244 β 1349 β -0.007 β
β Ashton Jeanty β 212 β 1340 β 0.112 β
β Marcus Carroll β 272 β 1336 β 0.026 β
ββββββββββββββββββββββ΄ββββββββββ΄βββββββββββ΄βββββββββββββββ
Recipe 9 β The most thrilling games of the year π’β
cfbfastR's schedule ships an excitement_index (a win-probability swinginess score). Sort it descending and you've ranked the season's white-knuckle finishes in one line.
thrillers = (
schedule
.filter(pl.col('excitement_index').is_not_null())
.sort('excitement_index', descending=True)
.select(['week', 'home_team', 'away_team',
'home_points', 'away_points', 'excitement_index'])
.head(10)
)
thrillers
shape: (10, 6)
ββββββββ¬βββββββββββββββββββ¬βββββββββββββββββ¬ββββββββββββββ¬ββββββββββββββ¬βββββββββββββββββββ
β week β home_team β away_team β home_points β away_points β excitement_index β
β --- β --- β --- β --- β --- β --- β
β i32 β str β str β i32 β i32 β f64 β
ββββββββͺβββββββββββββββββββͺβββββββββββββββββͺββββββββββββββͺββββββββββββββͺβββββββββββββββββββ‘
β 7 β Southern β Lincoln (CA) β 45 β 18 β 14.267416 β
β 9 β Western Carolina β Mercer β 38 β 45 β 13.938438 β
β 11 β Bucknell β Georgetown β 47 β 50 β 12.731991 β
β 3 β Tennessee State β Gardner-Webb β 27 β 25 β 12.041674 β
β 6 β Brown β Rhode Island β 30 β 34 β 11.825262 β
β 3 β Eastern Illinois β Illinois State β 14 β 13 β 11.431072 β
β 5 β Robert Morris β Howard β 10 β 35 β 11.33141 β
β 6 β Lindenwood β Tennessee Tech β 23 β 0 β 11.198615 β
β 10 β New Hampshire β Villanova β 33 β 45 β 11.10256 β
β 4 β Eastern Illinois β McNeese β 31 β 28 β 10.873191 β
ββββββββ΄βββββββββββββββββββ΄βββββββββββββββββ΄ββββββββββββββ΄ββββββββββββββ΄βββββββββββββββββββ
Recipe 10 β Where does the talent come from? πΊοΈβ
Roll the season roster up by home_state to map the recruiting footprint of college football β a quick reminder of just how much of the sport flows out of a handful of states.
talent_map = (
rosters
.filter(pl.col('home_state').is_not_null())
.group_by('home_state')
.agg(pl.len().alias('players'))
.sort('players', descending=True)
.head(15)
)
talent_map
shape: (15, 2)
ββββββββββββββ¬ββββββββββ
β home_state β players β
β --- β --- β
β str β u32 β
ββββββββββββββͺββββββββββ‘
β TX β 2526 β
β FL β 1853 β
β CA β 1748 β
β GA β 1584 β
β OH β 825 β
β β¦ β β¦ β
β PA β 574 β
β TN β 559 β
β NJ β 534 β
β MD β 512 β
β SC β 472 β
ββββββββββββββ΄ββββββββββ
Recipe 11 β Conference vs. non-conference, by margin πβ
The schedule's conference_game flag lets you split the slate. Restrict to FBS, then compare the average final margin in league play versus the out-of-conference cupcakes β group games are (predictably) tighter.
fbs = schedule.filter(
(pl.col('home_division') == 'fbs') & (pl.col('completed') == True)
)
splits = (
fbs
.with_columns(
(pl.col('home_points') - pl.col('away_points')).abs().alias('margin')
)
.group_by('conference_game')
.agg(
pl.len().alias('games'),
pl.col('margin').mean().round(1).alias('avg_margin'),
pl.col('home_points').add(pl.col('away_points'))
.mean().round(1).alias('avg_total_points'),
)
.sort('conference_game')
)
splits
shape: (2, 4)
βββββββββββββββββββ¬ββββββββ¬βββββββββββββ¬βββββββββββββββββββ
β conference_game β games β avg_margin β avg_total_points β
β --- β --- β --- β --- β
β bool β u32 β f64 β f64 β
βββββββββββββββββββͺββββββββͺβββββββββββββͺβββββββββββββββββββ‘
β false β 362 β 22.2 β 54.0 β
β true β 548 β 15.2 β 53.5 β
βββββββββββββββββββ΄ββββββββ΄βββββββββββββ΄βββββββββββββββββββ
Recipe 12 β Biggest betting favorites in history πΈβ
load_cfb_betting_lines is a premium release frame of historical sportsbook lines. Average the spread across books per game and sort to surface the most lopsided favorites β the mismatches Vegas saw coming a mile away.
lines = safe('load_cfb_betting_lines', sdv.cfb.load_cfb_betting_lines)
if lines is not None and {'season', 'market_type', 'lines',
'game_desc', 'abbr'}.issubset(lines.columns):
target = sorted(lines['season'].drop_nulls().unique().to_list())[-1]
favorites = (
lines
.filter((pl.col('season') == target)
& (pl.col('market_type') == 'spread')
& pl.col('lines').is_not_null())
.group_by(['game_desc', 'abbr'])
.agg(pl.col('lines').mean().round(1).alias('avg_spread'))
.filter(pl.col('avg_spread') < 0) # negative spread = favorite
.sort('avg_spread')
.head(10)
)
print(f'biggest favorites, {int(target)} season:')
out = favorites
else:
out = 'betting-lines frame unavailable right now'
out
β
load_cfb_betting_lines
biggest favorites, 2025 season:
shape: (10, 3)
ββββββββββββββββββββββββββββββββββ¬βββββββββββββ¬βββββββββββββ
β game_desc β abbr β avg_spread β
β --- β --- β --- β
β str β str β f64 β
ββββββββββββββββββββββββββββββββββͺβββββββββββββͺβββββββββββββ‘
β Arkansas-Pine Bluff@Texas Tech β Texas Tech β -54.5 β
β Bethune-Cookman@Miami β Miami β -54.5 β
β Samford@Texas A&M β Texas A&M β -54.2 β
β Grambling@Ohio State β Ohio State β -53.5 β
β Samford@Baylor β Baylor β -52.0 β
β The Citadel@Ole Miss β Ole Miss β -51.8 β
β Eastern Illinois@Alabama β Alabama β -51.3 β
β East Texas A&M@SMU β SMU β -50.8 β
β SE Louisiana@LSU β LSU β -48.8 β
β Northwestern State@Cincinnati β Cincinnati β -48.5 β
ββββββββββββββββββββββββββββββββββ΄βββββββββββββ΄βββββββββββββ
Recipe 13 β Hand it to pandas πΌβ
Every loader takes return_as_pandas=True, and any polars frame converts with .to_pandas(). Once it's a pandas DataFrame the whole pandas/numpy/scikit-learn world opens up β here, a one-call .describe() of scoring across the season.
score_pd = (
schedule
.select(['home_points', 'away_points'])
.to_pandas()
)
score_pd['total_points'] = score_pd['home_points'] + score_pd['away_points']
print(type(score_pd).__module__)
score_pd.describe().round(1)
pandas
home_points away_points total_points
count 3730.0 3730.0 3730.0
mean 28.9 24.3 53.1
std 16.0 15.0 17.8
min 0.0 0.0 0.0
25% 17.0 14.0 41.0
50% 28.0 23.0 52.0
75% 38.0 34.0 65.0
max 96.0 91.0 132.0
ποΈ Live tour: standings, polls, leaders & recruitsβ
A quick lap through the rest of the live ESPN surface. Each is wrapped in
safe() so the page renders cleanly whatever the feed is doing today.
| Function | Use it for |
|---|---|
espn_cfb_standings | conference / division standings |
espn_cfb_rankings | AP / Coaches / CFP polls |
espn_cfb_leaders | league stat leaders by category |
espn_cfb_recruits | a season's recruiting class |
standings = safe('ESPN standings', sdv.cfb.espn_cfb_standings)
rankings = safe('ESPN rankings (polls)', sdv.cfb.espn_cfb_rankings)
(standings.head()
if standings is not None and getattr(standings, 'height', 0)
else (rankings.head()
if rankings is not None and getattr(rankings, 'height', 0)
else 'standings & rankings unavailable right now'))
β
ESPN standings
β
ESPN rankings (polls)
shape: (5, 26)
ββββββββββββββ¬βββββββββββββ¬ββββββββββ¬βββββββββββββ¬ββββ¬βββββββββββββ¬ββββββββββββ¬ββββββββββββ¬βββββββββ
β group_name β group_abbr β team_id β team_name β β¦ β vs β vs. conf. β vs ap top β vs usa β
β --- β eviation β --- β --- β β division β --- β 25 β ranked β
β str β --- β str β str β β --- β str β --- β teams β
β β str β β β β str β β str β --- β
β β β β β β β β β str β
ββββββββββββββͺβββββββββββββͺββββββββββͺβββββββββββββͺββββͺβββββββββββββͺββββββββββββͺββββββββββββͺβββββββββ‘
β American β American β 5 β Blazers β β¦ β null β null β null β null β
β Conference β β β β β β β β β
β American β American β 58 β Bulls β β¦ β null β null β null β null β
β Conference β β β β β β β β β
β American β American β 151 β Pirates β β¦ β null β null β null β null β
β Conference β β β β β β β β β
β American β American β 202 β Golden β β¦ β null β null β null β null β
β Conference β β β Hurricane β β β β β β
β American β American β 218 β Owls β β¦ β null β null β null β null β
β Conference β β β β β β β β β
ββββββββββββββ΄βββββββββββββ΄ββββββββββ΄βββββββββββββ΄ββββ΄βββββββββββββ΄ββββββββββββ΄ββββββββββββ΄βββββββββ
leaders = safe(
'ESPN passing leaders',
lambda: sdv.cfb.espn_cfb_leaders(category='passingYards', season=2023, limit=15),
)
recruits = safe(
'ESPN recruiting class',
lambda: sdv.cfb.espn_cfb_recruits(season=2024, limit=25),
)
(leaders.head()
if leaders is not None and getattr(leaders, 'height', 0)
else (recruits.head()
if recruits is not None and getattr(recruits, 'height', 0)
else 'leaders & recruits unavailable right now'))
β
ESPN passing leaders
β
ESPN recruiting class
shape: (1, 2)
ββββββββ¬ββββββββββββββββββββββββββββββββββ
β code β message β
β --- β --- β
β i64 β str β
ββββββββͺββββββββββββββββββββββββββββββββββ‘
β 400 β http://sports.core.api.espn.pvβ¦ β
ββββββββ΄ββββββββββββββββββββββββββββββββββ
π§ͺ Bonus: process one game from scratch with CFBPlayProcessβ
Want EPA/WPA on a single live game without loading a whole season?
CFBPlayProcess drives the
full ESPN pipeline: .espn_cfb_pbp() fetches the raw summary, then
.run_processing_pipeline() returns a dict whose plays key is the
fully-featured play list (alongside an advanced box score and metadata).
from sportsdataverse.cfb import CFBPlayProcess
def process_game(game_id):
game = CFBPlayProcess(gameId=game_id)
game.espn_cfb_pbp()
processed = game.run_processing_pipeline()
return pl.DataFrame(processed['plays'], infer_schema_length=None)
plays = safe('CFBPlayProcess 401628334', lambda: process_game(401628334))
if plays is not None and plays.height:
cols = [c for c in ('period', 'pos_team', 'down', 'distance',
'play_type', 'EPA') if c in plays.columns]
out = plays.select(cols).head()
else:
out = 'live PBP pipeline quiet right now'
out
β
CFBPlayProcess 401628334
shape: (5, 5)
ββββββββββ¬βββββββββββ¬βββββββ¬βββββββββββ¬ββββββββββββ
β period β pos_team β down β distance β EPA β
β --- β --- β --- β --- β --- β
β i64 β i64 β i64 β i64 β f64 β
ββββββββββͺβββββββββββͺβββββββͺβββββββββββͺββββββββββββ‘
β 1 β 99 β 1 β 10 β -1.056855 β
β 1 β 99 β 1 β 10 β 1.160273 β
β 1 β 99 β 1 β 10 β 1.005587 β
β 1 β 99 β 1 β 10 β -0.563137 β
β 1 β 99 β 2 β 8 β 0.052125 β
ββββββββββ΄βββββββββββ΄βββββββ΄βββββββββββ΄ββββββββββββ
ποΈ stats.ncaa.org football β parse_cfb_ncaa_pbp + box parsersβ
New in 0.0.72: parsers for the stats.ncaa.org football surface.
parse_cfb_ncaa_pbp takes the raw HTML of a /contests/{id}/play_by_play
page and emits one tidy row per play, cfbfastR-style β drive context,
down/distance/yard line, a classified play_type, extracted
players/yards/kick details, and boolean flags β while cfb_ncaa_box adds the
box-score / drives / officials tables:
from sportsdataverse.cfb import parse_cfb_ncaa_pbp
pbp = parse_cfb_ncaa_pbp(html, contest_id=6081276) # html = captured page
pbp.filter(pl.col("touchdown") == True).head()
stats.ncaa.org is rate-limit unfriendly, so this family is parser-first:
feed it pages captured by your own fetch pipeline (the shared proxy-bound
NCAA fetch layer used by the ncaa_mbb_* family) rather than scraping live
in a notebook.
π Where to nextβ
- ποΈ Loaders are your premium fast-path β full reference on the
Loaders page (
load_cfb_pbp,load_cfb_rosters,load_cfb_schedule,load_cfb_team_info). - π‘ ESPN families live across the Site, Web, Core and Additional reference pages.
- πΌ Pass
return_as_pandas=Truefor pandas, orreturn_parsed=Falseon theespn_cfb_*wrappers for the raw JSON. - π₯ R user? The same verbs live in cfbfastR.
- Part of the SportsDataverse ecosystem.
Now go chart some chunk plays β and may your EPA always be positive! ππ