Skip to main content
Version: 0.0.70

πŸ’ Junior & minor hockey with sportsdataverse-py

Four leagues, one toolkit. sportsdataverse wraps the HockeyTech / LeagueStat feed behind the American Hockey League (AHL) and the three Canadian Hockey League major-junior loops β€” Ontario (OHL), Western (WHL) and Quebec Maritimes (QMJHL). They share an identical module shape, so learn one and you've learned all four. πŸŽ‰

And the best part: no API key needed β€” the public HockeyTech client keys ship with the package. Let's go scout some future pros!

πŸ—ΊοΈ The four leagues​

LeagueModuleLevel
AHLsportsdataverse.hockey.ahlMinor pro (one rung below the NHL)
OHLsportsdataverse.hockey.ohlCHL major junior
WHLsportsdataverse.hockey.whlCHL major junior
QMJHLsportsdataverse.hockey.qmjhlCHL major junior

Every module exposes the same <league>_* surface. Here's the AHL kit β€” swap ahl_ for ohl_ / whl_ / qmjhl_ and it all works identically. Every accessor returns polars by default (return_as_pandas=True for pandas):

FunctionWhat it gives you
ahl_scheduleGames + results, one row per game
ahl_standingsTeam standings
ahl_teamsTeams in a season (grab team_ids)
ahl_team_rosterA team's roster
ahl_pbpEvent-level play-by-play
ahl_player_statsA player's full season-by-season stat line
ahl_leadersStatistical leaders
ahl_game_summaryBox-score summary for a game (goals / penalties / stars)
ahl_game_corsiCorsi / Fenwick shot-attempt metrics
ahl_game_shiftsShift charts
ahl_player_toiTime-on-ice
ahl_season_id Β· most_recent_ahl_seasonSeason helpers

πŸ”Œ Setup​

pip install sportsdataverse
import sportsdataverse.hockey.ahl as ahl
import sportsdataverse.hockey.ohl as ohl
import sportsdataverse.hockey.whl as whl
import sportsdataverse.hockey.qmjhl as qmjhl

LEAGUES = {"ahl": ahl, "ohl": ohl, "whl": whl, "qmjhl": qmjhl}

Junior/minor feeds are seasonal and occasionally rate-limited, so a tiny safe() helper runs each call defensively β€” you get the frame when the feed is up, and a friendly one-liner when it isn't (never a scary traceback). πŸ›Ÿ

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

πŸ“… Schedule​

ahl_schedule (and its siblings) returns one row per game. Pass season=<end year> or let it default to the current season.

sched = safe("AHL schedule", lambda: ahl.ahl_schedule(season=ahl.most_recent_ahl_season()))
sched.shape if sched is not None else None
βœ… AHL schedule





(10000, 12)
cols = ["game_id", "game_date", "home_team", "away_team", "home_score", "away_score"]
(sched.select([c for c in cols if c in sched.columns]).head()
if sched is not None else "schedule unavailable")
shape: (5, 6)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_id ┆ game_date ┆ home_team ┆ away_team ┆ home_score ┆ away_score β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•ͺ══════════════════════════β•ͺ════════════════════β•ͺ══════════════β•ͺ════════════β•ͺ════════════║
β”‚ 1005950 ┆ 1995-01-17T20:00:00-05:0 ┆ U.S. AHL All-Stars ┆ Canadian AHL ┆ 4 ┆ 6 β”‚
β”‚ ┆ 0 ┆ ┆ All-Stars ┆ ┆ β”‚
β”‚ 1005949 ┆ 1996-01-16T19:30:00-05:0 ┆ U.S. AHL All-Stars ┆ Canadian AHL ┆ 6 ┆ 5 β”‚
β”‚ ┆ 0 ┆ ┆ All-Stars ┆ ┆ β”‚
β”‚ 1005948 ┆ 1997-01-16T20:30:00-04:0 ┆ Canadian AHL ┆ World AHL ┆ 2 ┆ 3 β”‚
β”‚ ┆ 0 ┆ All-Stars ┆ All-Stars ┆ ┆ β”‚
β”‚ 1005947 ┆ 1998-02-11T19:30:00-05:0 ┆ PlanetUSA AHL ┆ Canadian AHL ┆ 10 ┆ 11 β”‚
β”‚ ┆ 0 ┆ All-Stars ┆ All-Stars ┆ ┆ β”‚
β”‚ 1005946 ┆ 1999-01-25T20:00:00-05:0 ┆ PlanetUSA AHL ┆ Canadian AHL ┆ 5 ┆ 4 β”‚
β”‚ ┆ 0 ┆ All-Stars ┆ All-Stars ┆ ┆ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

🍳 Cookbook: common hockey tasks​

The shared surface makes these recipes work the same in every league β€” just swap the module. A dozen recipes follow, from leaderboards to box scores to pandas hand-offs; mix and match them however your scouting report needs. πŸ“‹

Recipe 1 β€” Standings table πŸ†β€‹

ohl_standings (shown for the OHL) gives one row per team.

standings = safe("OHL standings", lambda: ohl.ohl_standings(season=ohl.most_recent_ohl_season()))
cols = ["team", "games_played", "wins", "losses", "ot_losses", "points", "goals_for", "goals_against"]
(standings.select([c for c in cols if c in standings.columns]).head()
if standings is not None and standings.height else "standings unavailable")
βœ… OHL standings





shape: (5, 8)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ team ┆ games_played ┆ wins ┆ losses ┆ ot_losses ┆ points ┆ goals_for ┆ goals_against β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str ┆ i64 ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ══════════════β•ͺ══════β•ͺ════════β•ͺ═══════════β•ͺ════════β•ͺ═══════════β•ͺ═══════════════║
β”‚ Val-d'Or ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 β”‚
β”‚ Niagara IceDogs ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 β”‚
β”‚ North Bay ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 β”‚
β”‚ Battalion ┆ ┆ ┆ ┆ ┆ ┆ ┆ β”‚
β”‚ Brampton ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 β”‚
β”‚ Steelheads ┆ ┆ ┆ ┆ ┆ ┆ ┆ β”‚
β”‚ Sudbury Wolves ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 2 β€” A team and its roster πŸ‘₯​

List teams with whl_teams, grab a team_id, then pull the roster with whl_team_roster.

teams = safe("WHL teams", lambda: whl.whl_teams(season=whl.most_recent_whl_season()))
if teams is not None and teams.height:
tid_col = next((c for c in ("team_id", "id") if c in teams.columns), None)
tid = int(teams[tid_col][0]) if tid_col else None
roster = safe(f"WHL roster {tid}", lambda: whl.whl_team_roster(team_id=tid)) if tid else None
out = roster.head() if roster is not None and roster.height else teams.head()
else:
out = "teams unavailable"
out
βœ… WHL teams


βœ… WHL roster 201





shape: (5, 7)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ team_name ┆ team_id ┆ team_code ┆ team_nickname ┆ team_label ┆ division ┆ team_logo β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═════════β•ͺ═══════════β•ͺ═══════════════β•ͺ════════════β•ͺ══════════β•ͺ═════════════════║
β”‚ Brandon Wheat ┆ 201 ┆ BDN ┆ Wheat Kings ┆ Brandon ┆ 1 ┆ https://assets. β”‚
β”‚ Kings ┆ ┆ ┆ ┆ ┆ ┆ leaguestat.com/ β”‚
β”‚ ┆ ┆ ┆ ┆ ┆ ┆ … β”‚
β”‚ Calgary Hitmen ┆ 202 ┆ CGY ┆ Hitmen ┆ Calgary ┆ 3 ┆ https://assets. β”‚
β”‚ ┆ ┆ ┆ ┆ ┆ ┆ leaguestat.com/ β”‚
β”‚ ┆ ┆ ┆ ┆ ┆ ┆ … β”‚
β”‚ Edmonton Oil ┆ 228 ┆ EDM ┆ Oil Kings ┆ Edmonton ┆ 3 ┆ https://assets. β”‚
β”‚ Kings ┆ ┆ ┆ ┆ ┆ ┆ leaguestat.com/ β”‚
β”‚ ┆ ┆ ┆ ┆ ┆ ┆ … β”‚
β”‚ Everett ┆ 226 ┆ EVT ┆ Silvertips ┆ Everett ┆ 6 ┆ https://assets. β”‚
β”‚ Silvertips ┆ ┆ ┆ ┆ ┆ ┆ leaguestat.com/ β”‚
β”‚ ┆ ┆ ┆ ┆ ┆ ┆ … β”‚
β”‚ Kamloops Blazers ┆ 203 ┆ KAM ┆ Blazers ┆ Kamloops ┆ 2 ┆ https://assets. β”‚
β”‚ ┆ ┆ ┆ ┆ ┆ ┆ leaguestat.com/ β”‚
β”‚ ┆ ┆ ┆ ┆ ┆ ┆ … β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 3 β€” A game's play-by-play + shot attempts πŸ“ˆβ€‹

Take a game_id from the schedule, then ahl_pbp for events and ahl_game_corsi for Corsi/Fenwick β€” derived from the same feed.

import polars as pl

def latest_game_id(sched):
"""Most-recent game_id available in a schedule frame (or None)."""
if sched is None or not sched.height or "game_id" not in sched.columns:
return None
return int(sched.select(pl.col("game_id").cast(pl.Int64, strict=False)).max().item())

gid = latest_game_id(sched)
if gid is not None:
pbp = safe(f"AHL pbp {gid}", lambda: ahl.ahl_pbp(game_id=gid))
corsi = safe(f"AHL corsi {gid}", lambda: ahl.ahl_game_corsi(game_id=gid))
print("pbp rows:", None if pbp is None else pbp.height,
"| corsi rows:", None if corsi is None else corsi.height)
else:
print("no schedule rows to pick a game_id from")
βœ… AHL pbp 1020900


βœ… AHL corsi 1020900
pbp rows: 37 | corsi rows: 0

Recipe 4 β€” Compare all four leagues at once πŸ”β€‹

Because the surface is identical, one loop tours every league.

rows = []
for lg, mod in LEAGUES.items():
season = safe(f"{lg} season", getattr(mod, f'most_recent_{lg}_season'))
sch = safe(f"{lg} schedule", lambda mod=mod, lg=lg: getattr(mod, f'{lg}_schedule')()) if season else None
rows.append({"league": lg.upper(), "season": season, "games": None if sch is None else sch.height})
pl.DataFrame(rows)
βœ… ahl season


βœ… ahl schedule


βœ… ohl season


βœ… ohl schedule


βœ… whl season


βœ… whl schedule


βœ… qmjhl season


βœ… qmjhl schedule





shape: (4, 3)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
β”‚ league ┆ season ┆ games β”‚
β”‚ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ i64 ┆ i64 β”‚
β•žβ•β•β•β•β•β•β•β•β•ͺ════════β•ͺ═══════║
β”‚ AHL ┆ 2026 ┆ 10000 β”‚
β”‚ OHL ┆ 2027 ┆ 10000 β”‚
β”‚ WHL ┆ 2026 ┆ 10000 β”‚
β”‚ QMJHL ┆ 2027 ┆ 10000 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 5 β€” The scoring race πŸ₯‡β€‹

qmjhl_leaders hands back a ready-made leaderboard β€” rank, name, team_code, the stat_formatted value and what it measures (type_formatted). Same call, swap the module, and you've got every league's points race.

leaders = safe("QMJHL leaders", lambda: qmjhl.qmjhl_leaders(season=qmjhl.most_recent_qmjhl_season()))
cols = ["rank", "name", "team_code", "position", "stat_formatted", "type_formatted"]
(leaders.select([c for c in cols if c in leaders.columns]).head(10)
if leaders is not None and leaders.height else "leaders unavailable (offseason?)")
βœ… QMJHL leaders





'leaders unavailable (offseason?)'

Recipe 6 β€” Who's hot, who's not πŸŒ‘οΈβ€‹

Standings come back unsorted-ish β€” one sort on points puts the contenders on top and the lottery teams on the bottom. We also add a quick goal-differential column so you can see why a team is where it is.

st = safe("AHL standings", lambda: ahl.ahl_standings(season=ahl.most_recent_ahl_season()))
if st is not None and st.height and {"points", "goals_for", "goals_against"}.issubset(st.columns):
ranked = (
st.with_columns(
goal_diff=(pl.col("goals_for").cast(pl.Int64, strict=False)
- pl.col("goals_against").cast(pl.Int64, strict=False))
)
.sort(pl.col("points").cast(pl.Int64, strict=False), descending=True)
.select([c for c in ["team", "points", "goals_for", "goals_against", "goal_diff"]
if c in st.columns or c == "goal_diff"])
)
out = ranked.head()
else:
out = "standings unavailable"
out
βœ… AHL standings





shape: (4, 5)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ team ┆ points ┆ goals_for ┆ goals_against ┆ goal_diff β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ i64 ┆ str ┆ str ┆ i64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ════════β•ͺ═══════════β•ͺ═══════════════β•ͺ═══════════║
β”‚ Pacific Division All-Stars ┆ 6 ┆ 10 ┆ 8 ┆ 2 β”‚
β”‚ Atlantic Division All-Stars ┆ 3 ┆ 7 ┆ 4 ┆ 3 β”‚
β”‚ Central Division All-Stars ┆ 3 ┆ 8 ┆ 7 ┆ 1 β”‚
β”‚ North Division All-Stars ┆ 2 ┆ 4 ┆ 10 ┆ -6 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 7 β€” A player's career stat line πŸ“Šβ€‹

Grab any player_id (the leaderboard is a handy source) and whl_player_stats returns every season they've played β€” regular season, playoffs and exhibition, tagged by stat_type.

wlead = safe("WHL leaders", lambda: whl.whl_leaders(season=whl.most_recent_whl_season()))
if wlead is not None and wlead.height and "player_id" in wlead.columns:
pid = int(wlead["player_id"][0])
who = wlead["name"][0] if "name" in wlead.columns else pid
stats = safe(f"WHL stats for {who}", lambda: whl.whl_player_stats(player_id=pid))
cols = ["season_name", "team_name", "games_played", "goals", "assists", "points", "stat_type"]
out = (stats.select([c for c in cols if c in stats.columns]).head()
if stats is not None and stats.height else "no career rows")
else:
out = "leaders unavailable to source a player_id"
out
βœ… WHL leaders


βœ… WHL stats for Carson Carels





shape: (5, 7)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ season_name ┆ team_name ┆ games_played ┆ goals ┆ assists ┆ points ┆ stat_type β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════════β•ͺ══════════════β•ͺ═══════β•ͺ═════════β•ͺ════════β•ͺ════════════║
β”‚ 2025 - 26 Regular Season ┆ Prince George ┆ 58 ┆ 20 ┆ 53 ┆ 73 ┆ regular β”‚
β”‚ ┆ Cougars ┆ ┆ ┆ ┆ ┆ β”‚
β”‚ 2024 - 25 Regular Season ┆ Prince George ┆ 60 ┆ 6 ┆ 29 ┆ 35 ┆ regular β”‚
β”‚ ┆ Cougars ┆ ┆ ┆ ┆ ┆ β”‚
β”‚ 2023 - 24 Regular Season ┆ Prince George ┆ 7 ┆ 0 ┆ 3 ┆ 3 ┆ regular β”‚
β”‚ ┆ Cougars ┆ ┆ ┆ ┆ ┆ β”‚
β”‚ Total ┆ null ┆ 125 ┆ 26 ┆ 85 ┆ 111 ┆ regular β”‚
β”‚ WHL Prospects Game 2026 ┆ West ┆ 1 ┆ 0 ┆ 4 ┆ 4 ┆ exhibition β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 8 β€” The box score: goals & three stars πŸŒŸβ€‹

ohl_game_summary returns a dict of frames β€” game, goals, penalties, shots_by_period and three_stars. Here's the scoring summary plus the post-game stars for one game.

osched = safe("OHL schedule", lambda: ohl.ohl_schedule(season=ohl.most_recent_ohl_season()))
ogid = latest_game_id(osched)
if ogid is not None:
summ = safe(f"OHL game summary {ogid}", lambda: ohl.ohl_game_summary(game_id=ogid))
if summ is not None:
goals = summ.get("goals")
gcols = ["period_id", "time", "goal_scorer_team_code",
"goal_scorer_first_name", "goal_scorer_last_name", "power_play", "empty_net"]
out = (goals.select([c for c in gcols if c in goals.columns]).head()
if goals is not None and goals.height else "no goals frame")
else:
out = "summary unavailable"
else:
out = "no OHL game_id available"
out
βœ… OHL schedule


βœ… OHL game summary 16968





shape: (5, 7)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ period_id ┆ time ┆ goal_scorer_tea ┆ goal_scorer_fir ┆ goal_scorer_las ┆ power_play ┆ empty_net β”‚
β”‚ --- ┆ --- ┆ m_code ┆ st_name ┆ t_name ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ --- ┆ --- ┆ --- ┆ str ┆ str β”‚
β”‚ ┆ ┆ str ┆ str ┆ str ┆ ┆ β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════β•ͺ═════════════════β•ͺ═════════════════β•ͺ═════════════════β•ͺ════════════β•ͺ═══════════║
β”‚ 1 ┆ 4:33 ┆ SAG ┆ Brandon ┆ Saad ┆ 0 ┆ 0 β”‚
β”‚ 1 ┆ 15:47 ┆ SAG ┆ Brad ┆ Walch ┆ 1 ┆ 0 β”‚
β”‚ 2 ┆ 2:02 ┆ SAR ┆ Craig ┆ Hottot ┆ 0 ┆ 0 β”‚
β”‚ 2 ┆ 15:44 ┆ SAG ┆ Michael ┆ Sgarbossa ┆ 0 ┆ 0 β”‚
β”‚ 3 ┆ 10:05 ┆ SAR ┆ Brett ┆ Ritchie ┆ 0 ┆ 0 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

And the three-star selections from the same dict β€” no extra call needed:

if ogid is not None and 'summ' in dir() and summ is not None and summ.get("three_stars") is not None:
stars = summ["three_stars"]
scols = ["first_name", "last_name", "jersey_number", "home"]
out = (stars.select([c for c in scols if c in stars.columns])
if stars.height else "no three-star data for this game")
else:
out = "summary unavailable"
out
shape: (3, 4)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”
β”‚ first_name ┆ last_name ┆ jersey_number ┆ home β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ i64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•ͺ════════════β•ͺ═══════════════β•ͺ══════║
β”‚ Craig ┆ Hottot ┆ 16 ┆ 1 β”‚
β”‚ Michael ┆ Sgarbossa ┆ 93 ┆ 0 β”‚
β”‚ Alex ┆ Galchenyuk ┆ 94 ┆ 1 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜

Recipe 9 β€” Slice the play-by-play: just the goals ⛳​

The play-by-play frame carries a boolean goal flag, so pulling the scoring plays (with scorer names and the period clock) is a one-line filter.

if gid is not None:
pbp9 = safe(f"AHL pbp {gid}", lambda: ahl.ahl_pbp(game_id=gid))
if pbp9 is not None and pbp9.height and "goal" in pbp9.columns:
goals_only = pbp9.filter(pl.col("goal") == True) # noqa: E712 -- polars boolean
cols = ["period_of_game", "clock", "team_id",
"player_name_first", "player_name_last"]
out = (goals_only.select([c for c in cols if c in goals_only.columns])
if goals_only.height else "no goals parsed for this game")
else:
out = "play-by-play unavailable"
else:
out = "no game_id available"
out
βœ… AHL pbp 1020900





shape: (7, 5)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ period_of_game ┆ clock ┆ team_id ┆ player_name_first ┆ player_name_last β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════β•ͺ═════════β•ͺ═══════════════════β•ͺ══════════════════║
β”‚ 2 ┆ 14:28 ┆ 313 ┆ Patrick ┆ Sharp β”‚
β”‚ 2 ┆ 7:18 ┆ 313 ┆ Jon ┆ Sim β”‚
β”‚ 2 ┆ 1:55 ┆ 313 ┆ Ben ┆ Stafford β”‚
β”‚ 2 ┆ 0:13 ┆ 313 ┆ Jon ┆ Sim β”‚
β”‚ 3 ┆ 15:23 ┆ 330 ┆ Steve ┆ Maltais β”‚
β”‚ 3 ┆ 11:11 ┆ 313 ┆ Patrick ┆ Sharp β”‚
β”‚ 3 ┆ 3:53 ┆ 330 ┆ Lonny ┆ Bohonos β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 10 β€” Head-to-head history πŸ€β€‹

The schedule is just a frame, so a rivalry view is two str.contains filters. Pick the most-frequent home team, then every game where its top opponent visited β€” final scores included.

if sched is not None and sched.height and "home_team" in sched.columns:
top_home = sched["home_team"].value_counts(sort=True)["home_team"][0]
vs = sched.filter(pl.col("home_team") == top_home)
top_away = vs["away_team"].value_counts(sort=True)["away_team"][0] if vs.height else None
h2h = vs.filter(pl.col("away_team") == top_away) if top_away else vs
print(f"{top_home} hosting {top_away}:")
out = h2h.select([c for c in ["game_date", "home_team", "away_team", "home_score", "away_score"]
if c in h2h.columns]).head()
else:
out = "schedule unavailable"
out
Wilkes-Barre/Scranton Penguins hosting Hershey Bears:





shape: (5, 5)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_date ┆ home_team ┆ away_team ┆ home_score ┆ away_score β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════════════════β•ͺ═══════════════β•ͺ════════════β•ͺ════════════║
β”‚ 2005-01-28T19:05:00-05:00 ┆ Wilkes-Barre/Scranton ┆ Hershey Bears ┆ 2 ┆ 4 β”‚
β”‚ ┆ Penguins ┆ ┆ ┆ β”‚
β”‚ 2005-02-04T19:05:00-05:00 ┆ Wilkes-Barre/Scranton ┆ Hershey Bears ┆ 8 ┆ 2 β”‚
β”‚ ┆ Penguins ┆ ┆ ┆ β”‚
β”‚ 2005-02-11T19:05:00-05:00 ┆ Wilkes-Barre/Scranton ┆ Hershey Bears ┆ 0 ┆ 2 β”‚
β”‚ ┆ Penguins ┆ ┆ ┆ β”‚
β”‚ 2005-03-30T19:05:00-05:00 ┆ Wilkes-Barre/Scranton ┆ Hershey Bears ┆ 2 ┆ 3 β”‚
β”‚ ┆ Penguins ┆ ┆ ┆ β”‚
β”‚ 2005-04-08T19:05:00-04:00 ┆ Wilkes-Barre/Scranton ┆ Hershey Bears ┆ 3 ┆ 2 β”‚
β”‚ ┆ Penguins ┆ ┆ ┆ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 11 β€” Scout the roster: shooters & positions πŸ”β€‹

The roster frame is loaded with scouting fields. Here we break a team down by handedness (shoots) β€” a quick way to eyeball a power-play unit β€” straight from whl_team_roster.

rteams = safe("WHL teams", lambda: whl.whl_teams(season=whl.most_recent_whl_season()))
if rteams is not None and rteams.height and "team_id" in rteams.columns:
rtid = int(rteams["team_id"][0])
ros = safe(f"WHL roster {rtid}", lambda: whl.whl_team_roster(team_id=rtid))
if ros is not None and ros.height and "shoots" in ros.columns:
out = ros.group_by("shoots").len().sort("len", descending=True).rename({"len": "players"})
elif ros is not None and ros.height:
out = ros.head()
else:
out = "roster empty for this team/season"
else:
out = "teams unavailable"
out
βœ… WHL teams


βœ… WHL roster 201





'roster empty for this team/season'

Recipe 12 β€” Hand off to pandas πŸΌβ€‹

Every accessor takes return_as_pandas=True, so the whole toolkit drops straight into a pandas workflow. And ahl_season_id lists every season with its end-year and game-type label β€” handy for pinning an exact season_id instead of guessing the end year.

seasons = safe("AHL season_id", lambda: ahl.ahl_season_id(return_as_pandas=True))
if seasons is not None and len(seasons):
keep = [c for c in ["season_id", "season_name", "season_yr", "game_type_label"]
if c in seasons.columns]
recent = seasons[keep].sort_values("season_id", ascending=False).head()
print(type(recent).__module__) # -> pandas.core.frame
out = recent
else:
out = "season list unavailable"
out
βœ… AHL season_id
pandas





season_id season_name season_yr game_type_label
0 92 2026 Calder Cup Playoffs 2026 playoffs
1 91 2026 All-Star Challenge 2026 regular
2 90 2025-26 Regular Season 2026 regular
3 88 2025 Calder Cup Playoffs 2025 playoffs
4 87 2025 All-Star Challenge 2025 regular

πŸ₯… On-ice analytics​

Beyond the box score, the package derives advanced metrics from the same play-by-play feed:

FunctionMetric
ahl_game_corsiCorsi / Fenwick shot-attempt share
ahl_game_shiftsshift charts (who's on the ice)
ahl_player_toitime-on-ice per player
ahl_leadersstatistical leaders

Heads-up: shift-derived metrics (*_game_shifts, *_player_toi, *_game_corsi) depend on the league publishing a shift feed for that game β€” when it's absent you'll get an empty frame rather than an error, which is why the recipes above guard on height before using them. πŸ›Ÿ

πŸŽ‰ Where to next​

  • The same <league>_* calls work for ahl, ohl, whl and qmjhl β€” just swap the module.
  • Women's pro hockey? See the dedicated PWHL tutorial.
  • Full reference: the AHL / OHL / WHL / QMJHL pages in the sidebar.
  • Override a league's public key only if it rotates: SDV_<LEAGUE>_API_KEY.

Now go find the next first-overall pick! πŸ’