Skip to main content

🏈 College football with sportsdataverse-py

This page is the executed notebook 02_cfb_intro.ipynb: download it to run it yourself.

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:

  1. πŸ—„οΈ Release loaders (load_cfb_*) β€” pre-built, EPA/WPA-enriched datasets served straight from the cfbfastR-data GitHub release. Fast, reliable, no key needed.
  2. πŸ“‘ 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.

FunctionWhat it gives youSource
load_cfb_pbpFull play-by-play with EPA/WPA, since 2003⭐ release
load_cfb_rostersSeason rosters (bio, position, hometown)⭐ release
load_cfb_scheduleSeason schedule + results⭐ release
load_cfb_team_infoTeam metadata: conference, colors, venue⭐ release
load_cfb_ratingsOpponent-adjusted EPA ratings per team-season⭐ release
load_cfb_betting_linesHistorical betting market lines (spread/total/ML)⭐ release
espn_cfb_scoreboardLive + recent scoreboard for a date/week⭐ ESPN
espn_cfb_scheduleESPN schedule frame for a date/week⭐ ESPN
espn_cfb_teamsEvery FBS/FCS team (grab team_ids)⭐ ESPN
espn_cfb_team_rosterOne team's roster⭐ ESPN
espn_cfb_team_scheduleOne team's schedule⭐ ESPN
espn_cfb_standingsConference / division standings⭐ ESPN
espn_cfb_rankingsAP / Coaches / CFP polls⭐ ESPN
espn_cfb_leadersLeague stat leaders by category⭐ ESPN
espn_cfb_recruitsSeason recruiting class⭐ ESPN
espn_cfb_play_participantsPer-play athletes (passer/rusher/tackler…)⭐ ESPN
CFBPlayProcessFull ESPN PBP pipeline (EPA/WPA + box)⭐ ESPN
most_recent_cfb_seasonThe current season year helperhelper

πŸ”Œ 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: 2026

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. πŸ›Ÿ

from sportsdataverse.errors import AssetFetchError, NoDataError

def safe(label, thunk):
try:
out = thunk()
print(f'βœ… {label}')
return out
except (NoDataError, AssetFetchError) as e:
print(f"\u23ed\ufe0f {label}: {type(e).__name__}: {e}")
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 playoff fields baked in.

FunctionGrainHighlights
load_cfb_scheduleone row / gamescores, neutral-site & conference flags, playoff rounds
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, 43)





shape: (5, 8)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_id ┆ week ┆ home_team ┆ away_team ┆ home_point ┆ away_point ┆ home_confe ┆ neutral_si β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ s ┆ s ┆ rence ┆ te β”‚
β”‚ i64 ┆ i64 ┆ str ┆ str ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ ┆ ┆ ┆ ┆ i64 ┆ i64 ┆ str ┆ bool β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•ͺ══════β•ͺ═════════════β•ͺ═════════════β•ͺ════════════β•ͺ════════════β•ͺ════════════β•ͺ════════════║
β”‚ 401520145 ┆ 1 ┆ Jacksonvill ┆ UTEP ┆ 17 ┆ 14 ┆ Conference ┆ false β”‚
β”‚ ┆ ┆ e State ┆ ┆ ┆ ┆ USA ┆ β”‚
β”‚ 401520146 ┆ 1 ┆ Louisiana ┆ Florida Int ┆ 22 ┆ 17 ┆ Conference ┆ false β”‚
β”‚ ┆ ┆ Tech ┆ ernational ┆ ┆ ┆ USA ┆ β”‚
β”‚ 401520147 ┆ 1 ┆ Vanderbilt ┆ Hawai'i ┆ 35 ┆ 28 ┆ SEC ┆ false β”‚
β”‚ 401520148 ┆ 1 ┆ Minnesota ┆ Nebraska ┆ 13 ┆ 10 ┆ Big Ten ┆ false β”‚
β”‚ 401520149 ┆ 1 ┆ Alabama ┆ Middle ┆ 56 ┆ 7 ┆ SEC ┆ false β”‚
β”‚ ┆ ┆ ┆ Tennessee ┆ ┆ ┆ ┆ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ‘₯ 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_location',
'position', 'jersey', 'cfbd_home_state',
]).head()
rosters shape: (26375, 85)





shape: (5, 7)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ athlete_id ┆ first_name ┆ last_name ┆ team_location ┆ position ┆ jersey ┆ cfbd_home_state β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i64 ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•ͺ════════════β•ͺ═══════════β•ͺ═══════════════β•ͺ═══════════════β•ͺ════════β•ͺ═════════════════║
β”‚ 4360930 ┆ Jyaire ┆ Shorter ┆ Auburn ┆ Wide Receiver ┆ 4 ┆ TX β”‚
β”‚ 4362310 ┆ Shane ┆ Hooks ┆ Auburn ┆ Wide Receiver ┆ 3 ┆ FL β”‚
β”‚ 4373489 ┆ Kassidy ┆ Woods ┆ Auburn ┆ Wide Receiver ┆ 30 ┆ TX β”‚
β”‚ 4373959 ┆ Nick ┆ Mardner ┆ Auburn ┆ Wide Receiver ┆ 8 ┆ ON β”‚
β”‚ 4431277 ┆ Koy ┆ Moore ┆ Auburn ┆ Wide Receiver ┆ 5 ┆ LA β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

🏟️ 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: (672, 43)





shape: (5, 8)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
β”‚ team_id ┆ school ┆ conference ┆ classificati ┆ venue_name ┆ city ┆ state ┆ dome β”‚
β”‚ --- ┆ --- ┆ --- ┆ on ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i64 ┆ 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 ┆ InfoCision ┆ Akron ┆ OH ┆ false β”‚
β”‚ ┆ ┆ ┆ ┆ Stadium ┆ ┆ ┆ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜

🎬 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: (153945, 506)





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.609018 ┆ -0.0051 β”‚
β”‚ 401523986 ┆ San JosΓ© State ┆ 2 ┆ 10 ┆ 2.424552 ┆ 0.008039 β”‚
β”‚ 401523986 ┆ San JosΓ© State ┆ 1 ┆ 10 ┆ 0.156885 ┆ -0.000819 β”‚
β”‚ 401523986 ┆ San JosΓ© State ┆ 2 ┆ 5 ┆ -1.096584 ┆ -0.003242 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ“‘ 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 β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i64 ┆ 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 β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i64 ┆ 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 β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i64 ┆ str ┆ str ┆ i64 ┆ i64 ┆ i64 β”‚
β•žβ•β•β•β•β•β•β•ͺ══════════════════β•ͺ════════════════════β•ͺ═════════════β•ͺ═════════════β•ͺ══════════════║
β”‚ 9 ┆ Bowdoin ┆ Trinity (CT) ┆ null ┆ null ┆ null β”‚
β”‚ 9 ┆ Bates ┆ Williams ┆ null ┆ null ┆ null β”‚
β”‚ 9 ┆ Colby ┆ Middlebury ┆ null ┆ null ┆ null β”‚
β”‚ 11 ┆ Worcester State ┆ Framingham State ┆ null ┆ null ┆ null β”‚
β”‚ 10 ┆ Defiance College ┆ Rose Hulman ┆ 54 ┆ 78 ┆ 132 β”‚
β”‚ 10 ┆ Muskingum ┆ 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 (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 ┆ 946 ┆ 0.38 β”‚
β”‚ Oregon ┆ 1052 ┆ 0.311 β”‚
β”‚ Georgia ┆ 1064 ┆ 0.24 β”‚
β”‚ USC ┆ 958 ┆ 0.207 β”‚
β”‚ Washington ┆ 1121 ┆ 0.18 β”‚
β”‚ … ┆ … ┆ … β”‚
β”‚ Alabama ┆ 1030 ┆ 0.147 β”‚
β”‚ Michigan ┆ 1036 ┆ 0.143 β”‚
β”‚ Missouri ┆ 1004 ┆ 0.141 β”‚
β”‚ Texas ┆ 1106 ┆ 0.14 β”‚
β”‚ Notre Dame ┆ 917 ┆ 0.14 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

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_location') == team_name)
.select(['first_name', 'last_name', 'position', 'jersey',
'height', 'weight', 'cfbd_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: 143 players





shape: (10, 2)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ position ┆ players β”‚
β”‚ --- ┆ --- β”‚
β”‚ str ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═════════║
β”‚ Defensive Back ┆ 26 β”‚
β”‚ Offensive Lineman ┆ 21 β”‚
β”‚ Linebacker ┆ 18 β”‚
β”‚ Wide Receiver ┆ 18 β”‚
β”‚ Defensive Lineman ┆ 12 β”‚
β”‚ Defensive End ┆ 12 β”‚
β”‚ Running Back ┆ 11 β”‚
β”‚ Tight End ┆ 10 β”‚
β”‚ Place Kicker ┆ 6 β”‚
β”‚ Quarterback ┆ 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 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ══════β•ͺ════════β•ͺ═════════║
β”‚ South Dakota State ┆ 15 ┆ 0 ┆ 1.0 β”‚
β”‚ Harding ┆ 15 ┆ 0 ┆ 1.0 β”‚
β”‚ Michigan ┆ 15 ┆ 0 ┆ 1.0 β”‚
β”‚ SUNY Cortland ┆ 14 ┆ 1 ┆ 0.933 β”‚
β”‚ Washington ┆ 14 ┆ 1 ┆ 0.933 β”‚
β”‚ Colorado School of Mines ┆ 14 ┆ 1 ┆ 0.933 β”‚
β”‚ North Central College ┆ 14 ┆ 1 ┆ 0.933 β”‚
β”‚ Randolph Macon ┆ 13 ┆ 1 ┆ 0.929 β”‚
β”‚ Wartburg ┆ 13 ┆ 1 ┆ 0.929 β”‚
β”‚ Florida State ┆ 13 ┆ 1 ┆ 0.929 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 6 β€” End-of-season power ratings βš‘β€‹

load_cfb_ratings ships opponent-adjusted EPA ratings for every team-season: adj_net is adjusted offensive EPA per play minus adjusted defensive EPA per play, with ranks alongside. Join the schedule's team names onto its team_id for a ready-to-rank power table β€” no model to fit.

ratings = sdv.cfb.load_cfb_ratings(seasons=[2023])
names = pl.concat([
schedule.select(pl.col('home_id').alias('team_id'), pl.col('home_team').alias('team')),
schedule.select(pl.col('away_id').alias('team_id'), pl.col('away_team').alias('team')),
]).unique(subset=['team_id'])
assert ratings.schema['team_id'] == names.schema['team_id'] # join keys share one dtype
power = (
ratings
.join(names, on='team_id', how='left')
.sort('net_rank')
.select(['net_rank', 'team', 'adj_net', 'adj_off_epa', 'adj_def_epa', 'games'])
)
power.head(15)
shape: (15, 6)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
β”‚ net_rank ┆ team ┆ adj_net ┆ adj_off_epa ┆ adj_def_epa ┆ games β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i64 ┆ str ┆ f64 ┆ f64 ┆ f64 ┆ i64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•ͺ═══════════════β•ͺ══════════β•ͺ═════════════β•ͺ═════════════β•ͺ═══════║
β”‚ 1 ┆ Oregon ┆ 0.486063 ┆ 0.328165 ┆ -0.157897 ┆ 13 β”‚
β”‚ 2 ┆ Ohio State ┆ 0.46467 ┆ 0.182749 ┆ -0.281921 ┆ 12 β”‚
β”‚ 3 ┆ Michigan ┆ 0.431085 ┆ 0.205671 ┆ -0.225414 ┆ 15 β”‚
β”‚ 4 ┆ Georgia ┆ 0.420475 ┆ 0.26391 ┆ -0.156565 ┆ 13 β”‚
β”‚ 5 ┆ Oklahoma ┆ 0.401535 ┆ 0.22448 ┆ -0.177056 ┆ 13 β”‚
β”‚ … ┆ … ┆ … ┆ … ┆ … ┆ … β”‚
β”‚ 11 ┆ Missouri ┆ 0.293728 ┆ 0.13169 ┆ -0.162038 ┆ 12 β”‚
β”‚ 12 ┆ Florida State ┆ 0.274252 ┆ 0.094095 ┆ -0.180156 ┆ 13 β”‚
β”‚ 13 ┆ Tennessee ┆ 0.268064 ┆ 0.123445 ┆ -0.14462 ┆ 12 β”‚
β”‚ 14 ┆ Kansas State ┆ 0.264411 ┆ 0.155379 ┆ -0.109032 ┆ 12 β”‚
β”‚ 15 ┆ Washington ┆ 0.248367 ┆ 0.208181 ┆ -0.040186 ┆ 15 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜

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 β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i64 ┆ str ┆ i64 ┆ i64 ┆ i64 ┆ bool β”‚
β•žβ•β•β•β•β•β•β•ͺ═══════════════β•ͺ═════════β•ͺ═════════════β•ͺ════════β•ͺ══════════════║
β”‚ 1 ┆ Washington ┆ 34 ┆ 13 ┆ 21 ┆ true β”‚
β”‚ 1 ┆ East Carolina ┆ 30 ┆ 3 ┆ 27 ┆ false β”‚
β”‚ 1 ┆ Alabama ┆ 27 ┆ 20 ┆ 7 ┆ 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.074 β”‚
β”‚ Kimani Vidal ┆ 288 ┆ 1600 ┆ 0.019 β”‚
β”‚ Cody Schrader ┆ 274 ┆ 1585 ┆ 0.129 β”‚
β”‚ Tahj Brooks ┆ 289 ┆ 1570 ┆ 0.195 β”‚
β”‚ Omarion Hampton ┆ 243 ┆ 1503 ┆ 0.115 β”‚
β”‚ … ┆ … ┆ … ┆ … β”‚
β”‚ Makhi Hughes ┆ 255 ┆ 1365 ┆ 0.048 β”‚
β”‚ Ismail Mahdi ┆ 217 ┆ 1362 ┆ 0.134 β”‚
β”‚ Jaydn Ott ┆ 245 ┆ 1350 ┆ -0.006 β”‚
β”‚ Ashton Jeanty ┆ 212 ┆ 1340 ┆ 0.107 β”‚
β”‚ Marcus Carroll ┆ 273 ┆ 1340 ┆ 0.031 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 9 β€” The closest finishes of the year πŸŽ’β€‹

Rank completed FBS games by final margin, breaking ties toward the highest-scoring shootouts, and you've got the season's white-knuckle finishes in one expression.

thrillers = (
schedule
.filter((pl.col('completed') == True) & (pl.col('home_division') == 'fbs'))
.with_columns(
(pl.col('home_points') - pl.col('away_points')).abs().alias('margin'),
(pl.col('home_points') + pl.col('away_points')).alias('total_points'),
)
.sort(['margin', 'total_points'], descending=[False, True])
.select(['week', 'home_team', 'away_team',
'home_points', 'away_points', 'margin'])
.head(10)
)
thrillers
shape: (10, 6)
β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ week ┆ home_team ┆ away_team ┆ home_points ┆ away_points ┆ margin β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i64 ┆ str ┆ str ┆ i64 ┆ i64 ┆ i64 β”‚
β•žβ•β•β•β•β•β•β•ͺ════════════════β•ͺ═══════════════β•ͺ═════════════β•ͺ═════════════β•ͺ════════║
β”‚ 9 ┆ California ┆ USC ┆ 49 ┆ 50 ┆ 1 β”‚
β”‚ 5 ┆ UL Monroe ┆ App State ┆ 40 ┆ 41 ┆ 1 β”‚
β”‚ 5 ┆ UCF ┆ Baylor ┆ 35 ┆ 36 ┆ 1 β”‚
β”‚ 2 ┆ Virginia ┆ James Madison ┆ 35 ┆ 36 ┆ 1 β”‚
β”‚ 5 ┆ UConn ┆ Utah State ┆ 33 ┆ 34 ┆ 1 β”‚
β”‚ 12 ┆ Bowling Green ┆ Toledo ┆ 31 ┆ 32 ┆ 1 β”‚
β”‚ 7 ┆ Colorado State ┆ Boise State ┆ 31 ┆ 30 ┆ 1 β”‚
β”‚ 10 ┆ Minnesota ┆ Illinois ┆ 26 ┆ 27 ┆ 1 β”‚
β”‚ 10 ┆ Baylor ┆ Houston ┆ 24 ┆ 25 ┆ 1 β”‚
β”‚ 13 ┆ Old Dominion ┆ Georgia State ┆ 25 ┆ 24 ┆ 1 β”‚
β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜

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('cfbd_home_state').is_not_null())
.group_by('cfbd_home_state')
.agg(pl.len().alias('players'))
.sort('players', descending=True)
.head(15)
)
talent_map
shape: (15, 2)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ cfbd_home_state ┆ players β”‚
β”‚ --- ┆ --- β”‚
β”‚ str ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═════════║
β”‚ TX ┆ 2510 β”‚
β”‚ FL ┆ 1819 β”‚
β”‚ CA ┆ 1723 β”‚
β”‚ GA ┆ 1560 β”‚
β”‚ OH ┆ 791 β”‚
β”‚ … ┆ … β”‚
β”‚ TN ┆ 561 β”‚
β”‚ IL ┆ 542 β”‚
β”‚ MD ┆ 509 β”‚
β”‚ NJ ┆ 502 β”‚
β”‚ SC ┆ 467 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

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 β”‚
β”‚ Western Illinois@Illinois ┆ Illinois ┆ -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.

FunctionUse it for
espn_cfb_standingsconference / division standings
espn_cfb_rankingsAP / Coaches / CFP polls
espn_cfb_leadersleague stat leaders by category
espn_cfb_recruitsa 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, 27)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ group_name ┆ group_abbr ┆ team_id ┆ team_name ┆ … ┆ vs ┆ vs. conf. ┆ vs ap top ┆ vs usa β”‚
β”‚ --- ┆ eviation ┆ --- ┆ --- ┆ ┆ division ┆ --- ┆ 25 ┆ ranked β”‚
β”‚ str ┆ --- ┆ i64 ┆ str ┆ ┆ --- ┆ str ┆ --- ┆ teams β”‚
β”‚ ┆ str ┆ ┆ ┆ ┆ str ┆ ┆ str ┆ --- β”‚
β”‚ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•ͺ════════════β•ͺ═════════β•ͺ════════════β•ͺ═══β•ͺ════════════β•ͺ═══════════β•ͺ═══════════β•ͺ════════║
β”‚ American ┆ American ┆ 2636 ┆ Roadrunner ┆ … ┆ null ┆ null ┆ null ┆ null β”‚
β”‚ Conference ┆ ┆ ┆ s ┆ ┆ ┆ ┆ ┆ β”‚
β”‚ American ┆ American ┆ 235 ┆ Tigers ┆ … ┆ null ┆ null ┆ null ┆ null β”‚
β”‚ Conference ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ β”‚
β”‚ American ┆ American ┆ 249 ┆ Mean Green ┆ … ┆ null ┆ null ┆ null ┆ null β”‚
β”‚ Conference ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ β”‚
β”‚ American ┆ American ┆ 349 ┆ Black ┆ … ┆ null ┆ null ┆ null ┆ null β”‚
β”‚ Conference ┆ ┆ ┆ Knights ┆ ┆ ┆ ┆ ┆ β”‚
β”‚ American ┆ American ┆ 2226 ┆ Owls ┆ … ┆ null ┆ null ┆ null ┆ null β”‚
β”‚ Conference ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜
recruits = safe(
'ESPN recruiting class',
lambda: sdv.cfb.espn_cfb_recruits(season=2024, limit=25),
)
recruits.head() if recruits is not None and getattr(recruits, 'height', 0) else 'recruits unavailable right now'
βœ… ESPN recruiting class





shape: (5, 3)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ $ref ┆ count ┆ page_count β”‚
β”‚ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ i64 ┆ i64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════β•ͺ════════════║
β”‚ http://sports.core.api.espn.co… ┆ 4095 ┆ 164 β”‚
β”‚ http://sports.core.api.espn.co… ┆ 4095 ┆ 164 β”‚
β”‚ http://sports.core.api.espn.co… ┆ 4095 ┆ 164 β”‚
β”‚ http://sports.core.api.espn.co… ┆ 4095 ┆ 164 β”‚
β”‚ http://sports.core.api.espn.co… ┆ 4095 ┆ 164 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ§ͺ 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 ┆ -0.035415 β”‚
β”‚ 1 ┆ 99 ┆ 1 ┆ 10 ┆ 1.233441 β”‚
β”‚ 1 ┆ 99 ┆ 1 ┆ 10 ┆ 1.028077 β”‚
β”‚ 1 ┆ 99 ┆ 1 ┆ 10 ┆ -0.480993 β”‚
β”‚ 1 ┆ 99 ┆ 2 ┆ 8 ┆ 0.096702 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ›οΈ stats.ncaa.org football β€” parse_cfb_ncaa_pbp + box parsers​

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=True for pandas, or return_parsed=False on the espn_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! πŸ“ˆπŸˆ