Skip to main content
Version: 0.0.72

πŸ’ The PWHL with sportsdataverse-py

Welcome to professional women's hockey! The Professional Women's Hockey League (PWHL) dropped its first puck in January 2024 with six clubs β€” Boston, Minnesota, MontrΓ©al, New York, Ottawa and Toronto β€” and it's been must-watch hockey ever since. πŸŽ‰

sportsdataverse.pwhl gives you the whole league two ways:

  1. πŸ“¦ load_pwhl_* release loaders β€” fast, reliable parquet snapshots (schedules, boxscores, play-by-play, scoring & penalty summaries, rosters). Perfect for season-long analysis, and they work great offline.
  2. πŸ›°οΈ pwhl_* live wrappers + analytics β€” straight off the HockeyTech stats feed (standings, leaders, rosters, stats, single-game PBP) plus derived on-ice metrics (Corsi, time-on-ice, shifts).

And the best part: no API key needed β€” the public HockeyTech client key ships with the package. R companion: fastRhockey. Let's drop the puck! πŸ₯…

🧰 The toolbox​

Everything returns a tidy polars DataFrame by default β€” pass return_as_pandas=True for pandas. The πŸ“¦ loaders read pre-built release parquets (one season per call); the πŸ›°οΈ live wrappers hit the HockeyTech API in real time. Both are premium PWHL sources. Click any name for the full reference:

FunctionWhat it gives youSource
load_pwhl_scheduleGames + results, one row per gameπŸ“¦ loader
load_pwhl_rostersOne row per player per team (skaters + goalies)πŸ“¦ loader
load_pwhl_skater_boxSkater boxscore, one row per player per gameπŸ“¦ loader
load_pwhl_goalie_boxGoalie boxscore (saves, shots against, GAA inputs)πŸ“¦ loader
load_pwhl_team_boxTeam boxscore (shots, PP, faceoffs)πŸ“¦ loader
load_pwhl_pbpEvent-level play-by-play (wide, with coordinates)πŸ“¦ loader
load_pwhl_scoring_summaryTidy goal log (scorer + assists + situation flags)πŸ“¦ loader
load_pwhl_penalty_summaryTidy penalty log (infraction, minutes, who took it)πŸ“¦ loader
load_pwhl_shots_by_periodPer-period shot & goal totals per gameπŸ“¦ loader
load_pwhl_three_starsPost-game three-star selectionsπŸ“¦ loader
pwhl_scheduleLive schedule, one row per gameπŸ›°οΈ live
pwhl_standingsLive standings, one row per teamπŸ›°οΈ live
pwhl_teamsTeams in a season (grab team_ids)πŸ›°οΈ live
pwhl_team_rosterA team's rosterπŸ›°οΈ live
pwhl_leadersStatistical leadersπŸ›°οΈ live
pwhl_statsAggregate skater/goalie statsπŸ›°οΈ live
pwhl_player_searchFind a player_id by nameπŸ›°οΈ live
pwhl_player_statsA player's season-by-season stat linesπŸ›°οΈ live
pwhl_pbpEnriched single-game play-by-playπŸ›°οΈ live
pwhl_game_corsiOn-ice Corsi / Fenwick per playerπŸ›°οΈ live
pwhl_player_toiTime-on-ice per playerπŸ›°οΈ live
pwhl_game_shiftsRaw shift stintsπŸ›°οΈ live
most_recent_pwhl_season Β· pwhl_season_idSeason helpersπŸ›°οΈ live

πŸ”Œ Setup​

pip install sportsdataverse

No key, no config β€” just import and go.

import polars as pl
import sportsdataverse.pwhl as pwhl

# The inaugural season is 2024; this helper tracks the latest known season.
print("most recent PWHL season:", pwhl.most_recent_pwhl_season())
most recent PWHL season: 2027

The πŸ›°οΈ live HockeyTech feed is seasonal and occasionally rate-limited, so a tiny safe() helper runs those 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 πŸ“¦ loaders read release parquets and are rock-solid, so they don't need the wrapper. πŸ›Ÿ

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

πŸ“… The schedule (loader)​

load_pwhl_schedule returns one row per game with the result and a set of flag/URL columns pointing at the per-game feeds. Pass seasons=[2024] (a list β€” you can stack multiple seasons). ⚠️ Heads up: home_score/away_score come back as strings, so cast them before doing arithmetic.

schedule = pwhl.load_pwhl_schedule(seasons=[2024])
schedule.shape
(85, 29)
schedule.select([
'game_id', 'game_date', 'home_team', 'away_team',
'home_score', 'away_score', 'winner', 'game_type',
]).head()
shape: (5, 8)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_id ┆ game_date ┆ home_team ┆ away_team ┆ home_score ┆ away_score ┆ winner ┆ game_type β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•ͺ═════════════β•ͺ═══════════β•ͺ═══════════β•ͺ════════════β•ͺ════════════β•ͺ═══════════β•ͺ═══════════║
β”‚ 84 ┆ Wed, May 8 ┆ Toronto ┆ Minnesota ┆ 4 ┆ 0 ┆ Toronto ┆ playoffs β”‚
β”‚ 98 ┆ Wed, May 29 ┆ Boston ┆ Minnesota ┆ 0 ┆ 3 ┆ Minnesota ┆ playoffs β”‚
β”‚ 90 ┆ Wed, May 15 ┆ Minnesota ┆ Toronto ┆ 1 ┆ 0 ┆ Minnesota ┆ playoffs β”‚
β”‚ 63 ┆ Wed, May 1 ┆ Toronto ┆ Minnesota ┆ 4 ┆ 1 ┆ Toronto ┆ regular β”‚
β”‚ 45 ┆ Wed, Mar 6 ┆ Toronto ┆ Boston ┆ 3 ┆ 1 ┆ Toronto ┆ regular β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ‘₯ Rosters (loader)​

load_pwhl_rosters gives one row per player per team, split into skaters and goalies via the player_type column.

rosters = pwhl.load_pwhl_rosters(seasons=[2024])
rosters.select([
'team', 'team_abbr', 'player_type', 'first_name', 'last_name',
'jersey_number', 'position',
]).head()
shape: (5, 7)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ team ┆ team_abbr ┆ player_type ┆ first_name ┆ last_name ┆ jersey_number ┆ position β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str ┆ i32 ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ═════════════β•ͺ════════════β•ͺ═══════════β•ͺ═══════════════β•ͺ══════════║
β”‚ PWHL Toronto ┆ TOR ┆ skater ┆ Jocelyne ┆ Larocque ┆ 3 ┆ LD β”‚
β”‚ PWHL Toronto ┆ TOR ┆ skater ┆ Lauriane ┆ Rougeau ┆ 5 ┆ LD β”‚
β”‚ PWHL Toronto ┆ TOR ┆ skater ┆ Kali ┆ Flanagan ┆ 6 ┆ RD β”‚
β”‚ PWHL Toronto ┆ TOR ┆ skater ┆ Olivia ┆ Knowles ┆ 7 ┆ RD β”‚
β”‚ PWHL Toronto ┆ TOR ┆ skater ┆ Alexa ┆ Vasko ┆ 10 ┆ C β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ“Š Boxscores (loader)​

Boxscores come in three flavours β€” team_box, skater_box, and goalie_box β€” each one row per team/player per game.

FunctionOne row per…
load_pwhl_team_boxteam per game
load_pwhl_skater_boxskater per game
load_pwhl_goalie_boxgoalie per game
skater_box = pwhl.load_pwhl_skater_box(seasons=[2024])
skater_box.select([
'game_id', 'first_name', 'last_name', 'position',
'goals', 'assists', 'points', 'shots', 'plus_minus', 'time_on_ice',
]).head()
shape: (5, 10)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_id ┆ first_name ┆ last_name ┆ position ┆ … ┆ points ┆ shots ┆ plus_minus ┆ time_on_ice β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i32 ┆ str ┆ str ┆ str ┆ ┆ i32 ┆ i32 ┆ i32 ┆ f64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•ͺ════════════β•ͺ═══════════β•ͺ══════════β•ͺ═══β•ͺ════════β•ͺ═══════β•ͺ════════════β•ͺ═════════════║
β”‚ 2 ┆ Jocelyne ┆ Larocque ┆ LD ┆ … ┆ 0 ┆ 2 ┆ -2 ┆ 26.7 β”‚
β”‚ 2 ┆ Lauriane ┆ Rougeau ┆ LD ┆ … ┆ 0 ┆ 0 ┆ 0 ┆ 12.1 β”‚
β”‚ 2 ┆ Kali ┆ Flanagan ┆ RD ┆ … ┆ 0 ┆ 1 ┆ -1 ┆ 21.6 β”‚
β”‚ 2 ┆ Olivia ┆ Knowles ┆ RD ┆ … ┆ 0 ┆ 0 ┆ 0 ┆ 9.7 β”‚
β”‚ 2 ┆ Alexa ┆ Vasko ┆ C ┆ … ┆ 0 ┆ 3 ┆ 0 ┆ 10.5 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
goalie_box = pwhl.load_pwhl_goalie_box(seasons=[2024])
goalie_box.select([
'game_id', 'first_name', 'last_name',
'saves', 'shots_against', 'goals_against', 'time_on_ice',
]).head()
shape: (5, 7)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_id ┆ first_name ┆ last_name ┆ saves ┆ shots_against ┆ goals_against ┆ time_on_ice β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i32 ┆ str ┆ str ┆ i32 ┆ i32 ┆ i32 ┆ f64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•ͺ════════════β•ͺ════════════β•ͺ═══════β•ͺ═══════════════β•ͺ═══════════════β•ͺ═════════════║
β”‚ 2 ┆ Erica ┆ Howe ┆ 0 ┆ 0 ┆ 0 ┆ null β”‚
β”‚ 2 ┆ Kristen ┆ Campbell ┆ 24 ┆ 28 ┆ 4 ┆ 60.0 β”‚
β”‚ 2 ┆ Corinne ┆ Schroeder ┆ 29 ┆ 29 ┆ 0 ┆ 60.0 β”‚
β”‚ 2 ┆ Abbey ┆ Levy ┆ 0 ┆ 0 ┆ 0 ┆ null β”‚
β”‚ 3 ┆ Sandra ┆ Abstreiter ┆ 0 ┆ 0 ┆ 0 ┆ null β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

🎬 Play-by-play (loader)​

load_pwhl_pbp returns a wide event log. The event column tags each row as faceoff, shot, goal, or penalty β€” and there are several coordinate systems (x_coord/y_coord plus rink-normalized *_fixed / *_right variants) for drawing rink plots.

pbp = pwhl.load_pwhl_pbp(seasons=[2024])
pbp.shape
(14186, 104)
(pbp
.group_by('event')
.agg(pl.len().alias('events'))
.sort('events', descending=True))
shape: (9, 2)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ event ┆ events β”‚
β”‚ --- ┆ --- β”‚
β”‚ str ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ════════║
β”‚ shot ┆ 4922 β”‚
β”‚ faceoff ┆ 4631 β”‚
β”‚ hit ┆ 2123 β”‚
β”‚ blocked_shot ┆ 1243 β”‚
β”‚ penalty ┆ 518 β”‚
β”‚ goal ┆ 385 β”‚
β”‚ goalie_change ┆ 354 β”‚
β”‚ shootout ┆ 7 β”‚
β”‚ penaltyshot ┆ 3 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜

🍳 Cookbook: common PWHL tasks​

Now the fun part β€” a baker's dozen of recipes you'll reach for constantly. Recipes 1–11 lean on the rock-solid πŸ“¦ loaders (great offline); recipes 12–13 tour the πŸ›°οΈ live wrappers, wrapped in safe() so an offseason or a flaky feed never breaks your run. Every recipe ends in a tidy, ready-to-read frame.

Recipe 1 β€” Standings from the schedule πŸ†β€‹

No loader is needed for a quick standings table: the schedule's winner column makes a regular-season win count a one-liner.

(schedule
.filter(pl.col('game_type') == 'regular')
.group_by('winner')
.agg(pl.len().alias('wins'))
.sort('wins', descending=True))
shape: (6, 2)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”
β”‚ winner ┆ wins β”‚
β”‚ --- ┆ --- β”‚
β”‚ str ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•ͺ══════║
β”‚ Toronto ┆ 17 β”‚
β”‚ Montreal ┆ 13 β”‚
β”‚ Boston ┆ 12 β”‚
β”‚ Minnesota ┆ 12 β”‚
β”‚ Ottawa ┆ 9 β”‚
β”‚ New York ┆ 9 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜

Recipe 2 β€” Season scoring leaders πŸ₯‡β€‹

Aggregate the skater boxscore across every game to build a points leaderboard β€” the inaugural-season top of the table.

(skater_box
.group_by(['player_id', 'first_name', 'last_name'])
.agg(
pl.col('goals').sum().alias('goals'),
pl.col('assists').sum().alias('assists'),
pl.col('points').sum().alias('points'),
)
.sort('points', descending=True)
.select(['first_name', 'last_name', 'goals', 'assists', 'points'])
.head(10))
shape: (10, 5)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ first_name ┆ last_name ┆ goals ┆ assists ┆ points β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ i32 ┆ i32 ┆ i32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═════════════════β•ͺ═══════β•ͺ═════════β•ͺ════════║
β”‚ Natalie ┆ Spooner ┆ 21 ┆ 8 ┆ 29 β”‚
β”‚ Marie-Philip ┆ Poulin ┆ 11 ┆ 14 ┆ 25 β”‚
β”‚ Sarah ┆ Nurse ┆ 11 ┆ 13 ┆ 24 β”‚
β”‚ Alex ┆ Carpenter ┆ 8 ┆ 15 ┆ 23 β”‚
β”‚ Emma ┆ Maltais ┆ 5 ┆ 16 ┆ 21 β”‚
β”‚ Taylor ┆ Heise ┆ 9 ┆ 12 ┆ 21 β”‚
β”‚ Ella ┆ Shelton ┆ 7 ┆ 14 ┆ 21 β”‚
β”‚ Grace ┆ Zumwinkle ┆ 12 ┆ 8 ┆ 20 β”‚
β”‚ Brianne ┆ Jenner ┆ 9 ┆ 11 ┆ 20 β”‚
β”‚ Kendall ┆ Coyne Schofield ┆ 7 ┆ 13 ┆ 20 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 3 β€” Goalie save-percentage leaders πŸ§€β€‹

Sum saves and shots-against from the goalie boxscore, then compute a season save percentage. We require a minimum shot volume so a one-game cameo doesn't top the list.

(goalie_box
.group_by(['player_id', 'first_name', 'last_name'])
.agg(
pl.col('saves').sum().alias('saves'),
pl.col('shots_against').sum().alias('shots_against'),
pl.col('goals_against').sum().alias('goals_against'),
)
.filter(pl.col('shots_against') >= 100)
.with_columns(
(pl.col('saves') / pl.col('shots_against')).round(3).alias('save_pct')
)
.sort('save_pct', descending=True)
.select(['first_name', 'last_name', 'shots_against', 'goals_against', 'save_pct'])
.head(10))
shape: (10, 5)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ first_name ┆ last_name ┆ shots_against ┆ goals_against ┆ save_pct β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ i32 ┆ i32 ┆ f64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•ͺ════════════β•ͺ═══════════════β•ͺ═══════════════β•ͺ══════════║
β”‚ Elaine ┆ Chuli ┆ 253 ┆ 13 ┆ 0.949 β”‚
β”‚ Aerin ┆ Frankel ┆ 790 ┆ 49 ┆ 0.938 β”‚
β”‚ Kristen ┆ Campbell ┆ 718 ┆ 48 ┆ 0.933 β”‚
β”‚ Corinne ┆ Schroeder ┆ 511 ┆ 36 ┆ 0.93 β”‚
β”‚ Nicole ┆ Hensley ┆ 492 ┆ 37 ┆ 0.925 β”‚
β”‚ Maddie ┆ Rooney ┆ 362 ┆ 27 ┆ 0.925 β”‚
β”‚ Ann-RenΓ©e ┆ Desbiens ┆ 580 ┆ 44 ┆ 0.924 β”‚
β”‚ Emerance ┆ Maschmeyer ┆ 599 ┆ 51 ┆ 0.915 β”‚
β”‚ Abbey ┆ Levy ┆ 254 ┆ 24 ┆ 0.906 β”‚
β”‚ Emma ┆ SΓΆderberg ┆ 170 ┆ 17 ┆ 0.9 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 4 β€” Biggest blowouts of the season πŸ’₯​

Cast the string scores to integers, compute the margin, and sort β€” the season's most lopsided games fall right out.

(schedule
.with_columns(
pl.col('home_score').cast(pl.Int32),
pl.col('away_score').cast(pl.Int32),
)
.with_columns(
(pl.col('home_score') - pl.col('away_score')).abs().alias('margin')
)
.sort('margin', descending=True)
.select(['game_date', 'home_team', 'home_score',
'away_score', 'away_team', 'winner', 'margin'])
.head(10))
shape: (10, 7)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_date ┆ home_team ┆ home_score ┆ away_score ┆ away_team ┆ winner ┆ margin β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ i32 ┆ i32 ┆ str ┆ str ┆ i32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ════════════β•ͺ════════════β•ͺ═══════════β•ͺ═══════════β•ͺ════════║
β”‚ Wed, May 8 ┆ Toronto ┆ 4 ┆ 0 ┆ Minnesota ┆ Toronto ┆ 4 β”‚
β”‚ Wed, Mar 13 ┆ Minnesota ┆ 4 ┆ 0 ┆ Boston ┆ Minnesota ┆ 4 β”‚
β”‚ Sun, Apr 28 ┆ New York ┆ 2 ┆ 6 ┆ Toronto ┆ Toronto ┆ 4 β”‚
β”‚ Sat, Mar 16 ┆ Minnesota ┆ 5 ┆ 1 ┆ New York ┆ Minnesota ┆ 4 β”‚
β”‚ Sat, Jan 13 ┆ Toronto ┆ 1 ┆ 5 ┆ Ottawa ┆ Ottawa ┆ 4 β”‚
β”‚ Sat, Apr 20 ┆ Ottawa ┆ 4 ┆ 0 ┆ Minnesota ┆ Ottawa ┆ 4 β”‚
β”‚ Mon, Jan 1 ┆ Toronto ┆ 0 ┆ 4 ┆ New York ┆ New York ┆ 4 β”‚
β”‚ Wed, May 29 ┆ Boston ┆ 0 ┆ 3 ┆ Minnesota ┆ Minnesota ┆ 3 β”‚
β”‚ Wed, May 1 ┆ Toronto ┆ 4 ┆ 1 ┆ Minnesota ┆ Toronto ┆ 3 β”‚
β”‚ Wed, Mar 20 ┆ New York ┆ 0 ┆ 3 ┆ Ottawa ┆ Ottawa ┆ 3 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 5 β€” Team offense: shots & shooting % βš‘β€‹

Roll the team boxscore up to the club level for a quick offensive profile β€” total goals, shot volume, and finishing rate.

# Map each team_id to its abbreviation (both Int32-keyed), then roll up the
# skater box to the club level for a quick offensive profile.
team_lookup = (pwhl.load_pwhl_team_box(seasons=[2024])
.select(['team_id', 'team_abbr']).unique())

(skater_box
.join(team_lookup, on='team_id', how='left')
.group_by('team_abbr')
.agg(
pl.col('goals').sum().alias('goals'),
pl.col('shots').sum().alias('shots'),
)
.with_columns(
(pl.col('goals') / pl.col('shots') * 100).round(1).alias('shooting_pct')
)
.filter(pl.col('team_abbr').is_not_null())
.sort('goals', descending=True))
shape: (6, 4)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ team_abbr ┆ goals ┆ shots ┆ shooting_pct β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ i32 ┆ i32 ┆ f64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════β•ͺ═══════β•ͺ══════════════║
β”‚ TOR ┆ 74 ┆ 790 ┆ 9.4 β”‚
β”‚ MIN ┆ 72 ┆ 1025 ┆ 7.0 β”‚
β”‚ MTL ┆ 64 ┆ 814 ┆ 7.9 β”‚
β”‚ BOS ┆ 62 ┆ 907 ┆ 6.8 β”‚
β”‚ OTT ┆ 61 ┆ 721 ┆ 8.5 β”‚
β”‚ NY ┆ 52 ┆ 667 ┆ 7.8 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 6 β€” Power-play conversion leaders πŸ”Œβ€‹

The team boxscore carries pp_goals and pp_opportunities, so a season power-play percentage is a single division.

team_box = pwhl.load_pwhl_team_box(seasons=[2024])

(team_box
.group_by('team_abbr')
.agg(
pl.col('pp_goals').sum().alias('pp_goals'),
pl.col('pp_opportunities').sum().alias('pp_opportunities'),
)
.with_columns(
(pl.col('pp_goals') / pl.col('pp_opportunities') * 100).round(1).alias('pp_pct')
)
.sort('pp_pct', descending=True))
shape: (6, 4)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ team_abbr ┆ pp_goals ┆ pp_opportunities ┆ pp_pct β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ i32 ┆ i32 ┆ f64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•ͺ══════════β•ͺ══════════════════β•ͺ════════║
β”‚ OTT ┆ 16 ┆ 64 ┆ 25.0 β”‚
β”‚ NY ┆ 19 ┆ 78 ┆ 24.4 β”‚
β”‚ MTL ┆ 16 ┆ 94 ┆ 17.0 β”‚
β”‚ TOR ┆ 11 ┆ 80 ┆ 13.8 β”‚
β”‚ MIN ┆ 7 ┆ 87 ┆ 8.0 β”‚
β”‚ BOS ┆ 4 ┆ 68 ┆ 5.9 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 7 β€” Faceoff specialists πŸŽ―β€‹

The skater boxscore tracks faceoff wins and attempts. Aggregate, gate on a minimum-draw threshold, and the dot-dominators rise to the top.

(skater_box
.group_by(['first_name', 'last_name'])
.agg(
pl.col('faceoff_wins').sum().alias('fo_wins'),
pl.col('faceoff_attempts').sum().alias('fo_attempts'),
)
.filter(pl.col('fo_attempts') >= 200)
.with_columns(
(pl.col('fo_wins') / pl.col('fo_attempts') * 100).round(1).alias('fo_pct')
)
.sort('fo_pct', descending=True)
.head(10))
shape: (10, 5)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ first_name ┆ last_name ┆ fo_wins ┆ fo_attempts ┆ fo_pct β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ i32 ┆ i32 ┆ f64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════════β•ͺ═════════β•ͺ═════════════β•ͺ════════║
β”‚ Abby ┆ Roque ┆ 205 ┆ 339 ┆ 60.5 β”‚
β”‚ Marie-Philip ┆ Poulin ┆ 326 ┆ 546 ┆ 59.7 β”‚
β”‚ Alex ┆ Carpenter ┆ 245 ┆ 415 ┆ 59.0 β”‚
β”‚ Kelly ┆ Pannek ┆ 344 ┆ 630 ┆ 54.6 β”‚
β”‚ Brianne ┆ Jenner ┆ 125 ┆ 230 ┆ 54.3 β”‚
β”‚ Taylor ┆ Heise ┆ 264 ┆ 495 ┆ 53.3 β”‚
β”‚ Hannah ┆ Brandt ┆ 270 ┆ 510 ┆ 52.9 β”‚
β”‚ Kristin ┆ O'Neill ┆ 240 ┆ 460 ┆ 52.2 β”‚
β”‚ Jade ┆ Downie-Landry ┆ 116 ┆ 225 ┆ 51.6 β”‚
β”‚ Jesse ┆ Compher ┆ 119 ┆ 233 ┆ 51.1 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 8 β€” Two-way workhorses: hits + blocks πŸ§±β€‹

Not every contribution shows up on the scoresheet. Sum hits and blocked shots from the skater box to surface the players doing the dirty work β€” defenders usually own this list.

(skater_box
.group_by(['first_name', 'last_name', 'position'])
.agg(
pl.col('hits').sum().alias('hits'),
pl.col('blocked_shots').sum().alias('blocks'),
)
.with_columns(
(pl.col('hits') + pl.col('blocks')).alias('hits_plus_blocks')
)
.sort('hits_plus_blocks', descending=True)
.head(10))
shape: (10, 6)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ first_name ┆ last_name ┆ position ┆ hits ┆ blocks ┆ hits_plus_blocks β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ i32 ┆ i32 ┆ i32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•ͺ════════════β•ͺ══════════β•ͺ══════β•ͺ════════β•ͺ══════════════════║
β”‚ Renata ┆ Fast ┆ RD ┆ 77 ┆ 23 ┆ 100 β”‚
β”‚ Megan ┆ Keller ┆ LD ┆ 64 ┆ 33 ┆ 97 β”‚
β”‚ Kaleigh ┆ Fratkin ┆ RD ┆ 65 ┆ 19 ┆ 84 β”‚
β”‚ Blayre ┆ Turnbull ┆ C ┆ 62 ┆ 14 ┆ 76 β”‚
β”‚ Allie ┆ Munroe ┆ LD ┆ 44 ┆ 25 ┆ 69 β”‚
β”‚ Jessica ┆ DiGirolamo ┆ LD ┆ 36 ┆ 31 ┆ 67 β”‚
β”‚ Emma ┆ Maltais ┆ LW ┆ 53 ┆ 8 ┆ 61 β”‚
β”‚ Emma ┆ Greco ┆ LD ┆ 32 ┆ 29 ┆ 61 β”‚
β”‚ Lee ┆ Stecklein ┆ LD ┆ 36 ┆ 25 ┆ 61 β”‚
β”‚ Kelly ┆ Pannek ┆ C ┆ 28 ┆ 30 ┆ 58 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 9 β€” The penalty box πŸš¨β€‹

load_pwhl_penalty_summary is a tidy per-infraction log. Two quick cuts: the most common infractions league-wide, and the players spending the most time in the box.

penalties = pwhl.load_pwhl_penalty_summary(seasons=[2024])

# Most common infractions
top_infractions = (penalties
.group_by('description')
.agg(pl.len().alias('count'))
.sort('count', descending=True)
.head(8))
top_infractions
shape: (8, 2)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
β”‚ description ┆ count β”‚
β”‚ --- ┆ --- β”‚
β”‚ str ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════║
β”‚ Tripping ┆ 106 β”‚
β”‚ Hooking ┆ 91 β”‚
β”‚ Roughing ┆ 64 β”‚
β”‚ Interference ┆ 53 β”‚
β”‚ Slashing ┆ 35 β”‚
β”‚ Boarding ┆ 30 β”‚
β”‚ Holding ┆ 26 β”‚
β”‚ Cross Checking ┆ 26 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜
# PIM leaders (players who actually took the penalty)
(penalties
.filter(pl.col('taken_by_last').is_not_null())
.group_by(['taken_by_first', 'taken_by_last'])
.agg(
pl.col('minutes').sum().alias('pim'),
pl.len().alias('penalties'),
)
.sort('pim', descending=True)
.head(10))
shape: (10, 4)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ taken_by_first ┆ taken_by_last ┆ pim ┆ penalties β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ i32 ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════════β•ͺ═════β•ͺ═══════════║
β”‚ Tereza ┆ VaniΕ‘ovΓ‘ ┆ 37 ┆ 13 β”‚
β”‚ Kaleigh ┆ Fratkin ┆ 36 ┆ 18 β”‚
β”‚ Abby ┆ Roque ┆ 31 ┆ 10 β”‚
β”‚ Jesse ┆ Compher ┆ 25 ┆ 7 β”‚
β”‚ Megan ┆ Keller ┆ 22 ┆ 11 β”‚
β”‚ Allie ┆ Munroe ┆ 20 ┆ 10 β”‚
β”‚ Gabbie ┆ Hughes ┆ 20 ┆ 10 β”‚
β”‚ Sarah ┆ Nurse ┆ 18 ┆ 9 β”‚
β”‚ Renata ┆ Fast ┆ 18 ┆ 9 β”‚
β”‚ Sarah ┆ Bujold ┆ 18 ┆ 9 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 10 β€” When do goals get scored? ⏱️​

Slice the goal log out of the play-by-play and bucket it by period β€” and pull the league's top finishers straight from the event == 'goal' rows while you're there.

goal_events = pbp.filter(pl.col('event') == 'goal')

# Goals by period
goals_by_period = (goal_events
.group_by('period_of_game')
.agg(pl.len().alias('goals'))
.sort('period_of_game'))
goals_by_period
shape: (6, 2)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
β”‚ period_of_game ┆ goals β”‚
β”‚ --- ┆ --- β”‚
β”‚ str ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════║
β”‚ 1 ┆ 109 β”‚
β”‚ 2 ┆ 120 β”‚
β”‚ 3 ┆ 138 β”‚
β”‚ 4 ┆ 15 β”‚
β”‚ 5 ┆ 2 β”‚
β”‚ 6 ┆ 1 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜
# Top goal-scorers from the play-by-play feed
(goal_events
.filter(pl.col('player_name_last').is_not_null())
.group_by(['player_name_first', 'player_name_last'])
.agg(pl.len().alias('goals'))
.sort('goals', descending=True)
.head(10))
shape: (10, 3)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”
β”‚ player_name_first ┆ player_name_last ┆ goals β”‚
β”‚ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ══════════════════β•ͺ═══════║
β”‚ Natalie ┆ Spooner ┆ 21 β”‚
β”‚ Grace ┆ Zumwinkle ┆ 12 β”‚
β”‚ Sarah ┆ Nurse ┆ 11 β”‚
β”‚ Marie-Philip ┆ Poulin ┆ 11 β”‚
β”‚ Laura ┆ Stacey ┆ 10 β”‚
β”‚ Daryl ┆ Watts ┆ 10 β”‚
β”‚ Taylor ┆ Heise ┆ 9 β”‚
β”‚ Gabbie ┆ Hughes ┆ 9 β”‚
β”‚ Michela ┆ Cava ┆ 9 β”‚
β”‚ Brianne ┆ Jenner ┆ 9 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 11 β€” Three-stars honour roll ⭐ and a head-to-head series​

Two compact joins-on-themselves. First, who collected the most first-star nods (load_pwhl_three_stars). Then a head-to-head series view from the schedule β€” swap in any two clubs.

three_stars = pwhl.load_pwhl_three_stars(seasons=[2024])

# First-star honour roll
(three_stars
.filter(pl.col('star') == 1)
.group_by(['first_name', 'last_name'])
.agg(pl.len().alias('first_stars'))
.sort('first_stars', descending=True)
.head(10))
shape: (10, 3)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ first_name ┆ last_name ┆ first_stars β”‚
β”‚ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ═════════════║
β”‚ Natalie ┆ Spooner ┆ 7 β”‚
β”‚ Kristen ┆ Campbell ┆ 4 β”‚
β”‚ Nicole ┆ Hensley ┆ 4 β”‚
β”‚ Sarah ┆ Nurse ┆ 3 β”‚
β”‚ Hilary ┆ Knight ┆ 3 β”‚
β”‚ Marie-Philip ┆ Poulin ┆ 3 β”‚
β”‚ Alex ┆ Carpenter ┆ 3 β”‚
β”‚ Gabbie ┆ Hughes ┆ 3 β”‚
β”‚ Susanna ┆ Tapani ┆ 3 β”‚
β”‚ KateΕ™ina ┆ MrΓ‘zovΓ‘ ┆ 2 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
# Head-to-head: Boston vs. Montreal, every meeting in 2024
A, B = 'Boston', 'Montreal'
(schedule
.filter(
((pl.col('home_team') == A) & (pl.col('away_team') == B)) |
((pl.col('home_team') == B) & (pl.col('away_team') == A))
)
.select(['game_date', 'home_team', 'home_score',
'away_score', 'away_team', 'winner', 'game_status']))
shape: (7, 7)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_date ┆ home_team ┆ home_score ┆ away_score ┆ away_team ┆ winner ┆ game_status β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ════════════β•ͺ════════════β•ͺ═══════════β•ͺ══════════β•ͺ═════════════║
β”‚ Tue, May 14 ┆ Boston ┆ 3 ┆ 2 ┆ Montreal ┆ Boston ┆ Final OT β”‚
β”‚ Thu, May 9 ┆ Montreal ┆ 1 ┆ 2 ┆ Boston ┆ Boston ┆ Final OT β”‚
β”‚ Sun, Feb 4 ┆ Boston ┆ 1 ┆ 2 ┆ Montreal ┆ Montreal ┆ Final OT β”‚
β”‚ Sat, May 4 ┆ Boston ┆ 4 ┆ 3 ┆ Montreal ┆ Boston ┆ Final β”‚
β”‚ Sat, May 11 ┆ Montreal ┆ 1 ┆ 2 ┆ Boston ┆ Boston ┆ Final OT3 β”‚
β”‚ Sat, Mar 2 ┆ Montreal ┆ 3 ┆ 1 ┆ Boston ┆ Montreal ┆ Final β”‚
β”‚ Sat, Jan 13 ┆ Montreal ┆ 2 ┆ 3 ┆ Boston ┆ Boston ┆ Final OT β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 12 β€” Find a player, then pull her career lines πŸ›°οΈπŸ”Žβ€‹

A classic two-step lookup off the live feed: pwhl_player_search resolves a name to a player_id, then pwhl_player_stats returns her season-by-season stat lines. Both are safe()-wrapped for offseason resilience.

hit = safe('player search: Spooner', lambda: pwhl.pwhl_player_search('Spooner'))
if hit is not None and getattr(hit, 'height', 0):
pid = int(hit['player_id'][0])
career = safe(f'player stats {pid}', lambda: pwhl.pwhl_player_stats(player_id=pid))
if career is not None and career.height:
keep = [c for c in ['season_name', 'team_code', 'games_played',
'goals', 'assists', 'points', 'points_per_game']
if c in career.columns]
out = career.select(keep)
else:
out = 'player stats feed unavailable right now'
else:
out = 'player search feed unavailable right now'
out
βœ… player search: Spooner


βœ… player stats 100





shape: (10, 7)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ season_name ┆ team_code ┆ games_played ┆ goals ┆ assists ┆ points ┆ points_per_game β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ══════════════β•ͺ═══════β•ͺ═════════β•ͺ════════β•ͺ═════════════════║
β”‚ 2025-26 Regular Season ┆ TOR ┆ 30 ┆ 3 ┆ 5 ┆ 8 ┆ 0.27 β”‚
β”‚ 2024-25 Regular Season ┆ TOR ┆ 14 ┆ 3 ┆ 2 ┆ 5 ┆ 0.36 β”‚
β”‚ 2024 Regular Season ┆ TOR ┆ 24 ┆ 20 ┆ 7 ┆ 27 ┆ 1.13 β”‚
β”‚ Total ┆ null ┆ 68 ┆ 26 ┆ 14 ┆ 40 ┆ 0.59 β”‚
β”‚ 2025-26 Preseason ┆ TOR ┆ 1 ┆ 0 ┆ 1 ┆ 1 ┆ 1.00 β”‚
β”‚ 2024 Preseason ┆ TOR ┆ 1 ┆ 0 ┆ 0 ┆ 0 ┆ 0.00 β”‚
β”‚ Total ┆ null ┆ 2 ┆ 0 ┆ 1 ┆ 1 ┆ 0.50 β”‚
β”‚ 2025 Playoffs ┆ TOR ┆ 4 ┆ 0 ┆ 1 ┆ 1 ┆ 0.25 β”‚
β”‚ 2024 Playoffs ┆ TOR ┆ 3 ┆ 1 ┆ 1 ┆ 2 ┆ 0.67 β”‚
β”‚ Total ┆ null ┆ 7 ┆ 1 ┆ 2 ┆ 3 ┆ 0.43 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Recipe 13 β€” A team, its roster, and a game's PBP + Corsi πŸ›°οΈπŸ“ˆβ€‹

The full live tour. List teams with pwhl_teams, grab a team_id, pull the roster with pwhl_team_roster, take a game_id from the loader schedule, then fetch enriched events with pwhl_pbp and shot-attempt share with pwhl_game_corsi β€” all from the same feed. Everything is safe()-wrapped, so offline this prints a friendly note instead of raising.

teams = safe('PWHL teams', lambda: pwhl.pwhl_teams(season=2024))
if teams is not None and teams.height:
tid = int(teams['team_id'][0])
roster = safe(f'PWHL roster {tid}', lambda: pwhl.pwhl_team_roster(team_id=tid, season=2024))
out = (roster.select([c for c in ['first_name', 'last_name', 'position', 'jersey_number']
if c in roster.columns]).head()
if roster is not None else teams.head())
else:
out = 'teams feed unavailable right now'
out
βœ… PWHL teams


βœ… PWHL roster 1





shape: (5, 3)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ first_name ┆ last_name ┆ position β”‚
β”‚ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ══════════║
β”‚ Emily ┆ Brown ┆ D β”‚
β”‚ Megan ┆ Keller ┆ D β”‚
β”‚ Sidney ┆ Morin ┆ D β”‚
β”‚ Lexie ┆ Adzija ┆ F β”‚
β”‚ Sophie ┆ Shirley ┆ F β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
# A game_id from the loader schedule (offline-safe), then enrich it live.
gid = int(schedule['game_id'][0])
pbp_live = safe(f'PWHL pbp {gid}', lambda: pwhl.pwhl_pbp(game_id=gid))
corsi = safe(f'PWHL corsi {gid}', lambda: pwhl.pwhl_game_corsi(game_id=gid))
print('live pbp rows:', None if pbp_live is None else pbp_live.height,
'| corsi rows:', None if corsi is None else corsi.height)
βœ… PWHL pbp 84


βœ… PWHL corsi 84
live pbp rows: 188 | corsi rows: 39

πŸ›°οΈ Live standings & leaders​

Straight off the HockeyTech feed: pwhl_standings for the live table and pwhl_leaders for the statistical leaderboard. Both take a season end-year. We keep them safe()-wrapped because live endpoints are seasonal.

standings = safe('PWHL standings', lambda: pwhl.pwhl_standings(season=2024))
if standings is not None and standings.height:
keep = [c for c in ['team', 'team_code', 'games_played', 'wins', 'losses', 'points']
if c in standings.columns]
out = standings.select(keep).head(10)
else:
out = 'standings feed unavailable right now'
out
βœ… PWHL standings





shape: (6, 6)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ team ┆ team_code ┆ games_played ┆ wins ┆ losses ┆ points β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ str ┆ i64 ┆ str ┆ i64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ══════════════β•ͺ══════β•ͺ════════β•ͺ════════║
β”‚ x - PWHL Toronto ┆ x - TOR ┆ 24 ┆ 17 ┆ 7 ┆ 47 β”‚
β”‚ x - PWHL Montreal ┆ x - MTL ┆ 24 ┆ 13 ┆ 6 ┆ 41 β”‚
β”‚ x - PWHL Boston ┆ x - BOS ┆ 24 ┆ 12 ┆ 9 ┆ 35 β”‚
β”‚ x - PWHL Minnesota ┆ x - MIN ┆ 24 ┆ 12 ┆ 9 ┆ 35 β”‚
β”‚ e - PWHL Ottawa ┆ e - OTT ┆ 24 ┆ 9 ┆ 9 ┆ 32 β”‚
β”‚ e - PWHL New York ┆ e - NY ┆ 24 ┆ 9 ┆ 12 ┆ 26 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜
leaders = safe('PWHL leaders', lambda: pwhl.pwhl_leaders(season=2024))
if leaders is not None and getattr(leaders, 'height', 0):
keep = [c for c in ['rank', 'name', 'team_code', 'stat_formatted', 'type_formatted']
if c in leaders.columns]
out = leaders.select(keep).head(10)
else:
out = 'leaders feed unavailable right now'
out
βœ… PWHL leaders





shape: (10, 5)
β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ rank ┆ name ┆ team_code ┆ stat_formatted ┆ type_formatted β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i64 ┆ str ┆ str ┆ str ┆ str β”‚
β•žβ•β•β•β•β•β•β•ͺ═════════════════════β•ͺ═══════════β•ͺ════════════════β•ͺ════════════════║
β”‚ 1 ┆ Natalie Spooner ┆ TOR ┆ 27 ┆ Points β”‚
β”‚ 2 ┆ Sarah Nurse ┆ TOR ┆ 23 ┆ Points β”‚
β”‚ 3 ┆ Marie-Philip Poulin ┆ MTL ┆ 23 ┆ Points β”‚
β”‚ 4 ┆ Alex Carpenter ┆ NY ┆ 23 ┆ Points β”‚
β”‚ 5 ┆ Ella Shelton ┆ NY ┆ 21 ┆ Points β”‚
β”‚ 1 ┆ Natalie Spooner ┆ TOR ┆ 20 ┆ Goals β”‚
β”‚ 2 ┆ Sarah Nurse ┆ TOR ┆ 11 ┆ Goals β”‚
β”‚ 3 ┆ Grace Zumwinkle ┆ MIN ┆ 11 ┆ Goals β”‚
β”‚ 4 ┆ Marie-Philip Poulin ┆ MTL ┆ 10 ┆ Goals β”‚
β”‚ 5 ┆ Laura Stacey ┆ MTL ┆ 10 ┆ Goals β”‚
β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ₯… On-ice analytics​

Beyond the box score, three analytics helpers derive advanced metrics from the same shift + play-by-play feed:

FunctionMetric
pwhl_game_corsiCorsi / Fenwick shot-attempt share, with per-60 rates
pwhl_player_toisummed time-on-ice + shift counts per player
pwhl_game_shiftsraw shift stints (who's on the ice, when)

⚠️ Corsi note: the HockeyTech feed has no missed-shot event, so Corsi and Fenwick here are proxies counting shots + blocked shots + goals only (corsi_includes_missed = False).

toi = safe(f'PWHL TOI {gid}', lambda: pwhl.pwhl_player_toi(game_id=gid))
if toi is not None and toi.height:
out = (toi.select([c for c in ['first_name', 'last_name', 'toi_seconds', 'num_shifts']
if c in toi.columns])
.sort('toi_seconds', descending=True).head())
else:
out = 'time-on-ice feed unavailable right now'
out
βœ… PWHL TOI 84





shape: (5, 4)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ first_name ┆ last_name ┆ toi_seconds ┆ num_shifts β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ str ┆ i64 ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ═════════════β•ͺ════════════║
β”‚ Nicole ┆ Hensley ┆ 3600 ┆ 3 β”‚
β”‚ Kristen ┆ Campbell ┆ 3600 ┆ 3 β”‚
β”‚ Jocelyne ┆ Larocque ┆ 1677 ┆ 29 β”‚
β”‚ Renata ┆ Fast ┆ 1674 ┆ 28 β”‚
β”‚ Sophie ┆ Jaques ┆ 1402 ┆ 26 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
if corsi is not None and corsi.height:
out = (corsi
.with_columns((pl.col('corsi_for') - pl.col('corsi_against')).alias('corsi_net'))
.select([c for c in ['player_id', 'corsi_for', 'corsi_against', 'corsi_net', 'corsi_for_per60']
if c in corsi.columns])
.sort('corsi_for_per60', descending=True)
.head())
else:
out = 'corsi feed unavailable right now'
out
shape: (5, 4)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ player_id ┆ corsi_for ┆ corsi_against ┆ corsi_for_per60 β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ str ┆ i64 ┆ i64 ┆ f64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ═══════════════β•ͺ═════════════════║
β”‚ 115 ┆ 16 ┆ 2 ┆ 74.805195 β”‚
β”‚ 76 ┆ 17 ┆ 4 ┆ 66.521739 β”‚
β”‚ 89 ┆ 15 ┆ 6 ┆ 64.362336 β”‚
β”‚ 100 ┆ 17 ┆ 8 ┆ 59.824047 β”‚
β”‚ 20 ┆ 16 ┆ 14 ┆ 51.382694 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

✨ Bonus: tidy goal log + pandas interop​

load_pwhl_scoring_summary is a clean per-goal log β€” scorer plus up to two assists, with situation flags like power play, short handed, and game-winning. And because every loader takes return_as_pandas=True, dropping into the pandas world is one keyword away.

scoring = pwhl.load_pwhl_scoring_summary(seasons=[2024])
scoring.select([
'game_id', 'period', 'time', 'team_abbr',
'scorer_first', 'scorer_last', 'is_power_play', 'is_game_winning',
]).head()
shape: (5, 8)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_id ┆ period ┆ time ┆ team_abbr ┆ scorer_first ┆ scorer_last ┆ is_power_play ┆ is_game_winn β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ ing β”‚
β”‚ i32 ┆ str ┆ str ┆ str ┆ str ┆ str ┆ i32 ┆ --- β”‚
β”‚ ┆ ┆ ┆ ┆ ┆ ┆ ┆ i32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•ͺ════════β•ͺ═══════β•ͺ═══════════β•ͺ══════════════β•ͺ═════════════β•ͺ═══════════════β•ͺ══════════════║
β”‚ 2 ┆ 1st ┆ 10:43 ┆ NY ┆ Ella ┆ Shelton ┆ 0 ┆ 1 β”‚
β”‚ 2 ┆ 3rd ┆ 2:53 ┆ NY ┆ Alex ┆ Carpenter ┆ 0 ┆ 0 β”‚
β”‚ 2 ┆ 3rd ┆ 4:57 ┆ NY ┆ Jill ┆ Saulnier ┆ 0 ┆ 0 β”‚
β”‚ 2 ┆ 3rd ┆ 7:42 ┆ NY ┆ Kayla ┆ Vespa ┆ 0 ┆ 0 β”‚
β”‚ 3 ┆ 2nd ┆ 16:24 ┆ OTT ┆ Hayley ┆ Scamurra ┆ 1 ┆ 0 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
# Same skater box, but as a pandas DataFrame β€” group with the pandas API.
skater_pd = pwhl.load_pwhl_skater_box(seasons=[2024], return_as_pandas=True)
print('type:', type(skater_pd).__name__, '| shape:', skater_pd.shape)
(skater_pd
.groupby(['first_name', 'last_name'], as_index=False)['points'].sum()
.sort_values('points', ascending=False)
.head(10))
type: DataFrame | shape: (3205, 22)





first_name last_name points
101 Natalie Spooner 29
90 Marie-Philip Poulin 25
114 Sarah Nurse 24
4 Alex Carpenter 23
35 Ella Shelton 21
40 Emma Maltais 21
126 Taylor Heise 21
18 Brianne Jenner 20
42 Erin Ambrose 20
47 Grace Zumwinkle 20

⏱️ Shifts, strength state, and shot-level xG​

New in 0.0.72: two published PWHL dataset releases. load_pwhl_shifts is the shift-chart table backing the real on-ice strength_state (EV/PP/SH), and load_pwhl_xg_pbp is the play-by-play enriched with shot-level, coordinate-based expected goals:

from sportsdataverse.pwhl import load_pwhl_shifts, load_pwhl_xg_pbp

shifts = load_pwhl_shifts(seasons=[2025])
xg = load_pwhl_xg_pbp(seasons=[2025])
print("shifts:", shifts.shape, "| xg pbp:", xg.shape)
xg.select(["game_id", "event_type", "shot_distance", "shot_angle", "xg"]).drop_nulls("xg").head()
shifts: (81546, 14) | xg pbp: (5671, 21)





shape: (5, 5)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ game_id ┆ event_type ┆ shot_distance ┆ shot_angle ┆ xg β”‚
β”‚ --- ┆ --- ┆ --- ┆ --- ┆ --- β”‚
β”‚ i32 ┆ str ┆ f64 ┆ f64 ┆ f64 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•ͺ════════════β•ͺ═══════════════β•ͺ════════════β•ͺ══════════║
β”‚ 105 ┆ Default ┆ 38.6912 ┆ 33.3136 ┆ 0.050235 β”‚
β”‚ 105 ┆ Default ┆ 46.5487 ┆ 21.4205 ┆ 0.053222 β”‚
β”‚ 105 ┆ Default ┆ 24.8723 ┆ 17.9129 ┆ 0.082214 β”‚
β”‚ 105 ┆ Default ┆ 36.8333 ┆ 22.6199 ┆ 0.06718 β”‚
β”‚ 105 ┆ Default ┆ 25.5563 ┆ 61.145 ┆ 0.061872 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸŽ‰ Where to next​

  • πŸ“¦ Loaders are your offline-friendly workhorses β€” stack seasons with seasons=[2024, 2025] and pass return_as_pandas=True for pandas.
  • πŸ›°οΈ Live wrappers (pwhl_*) pull fresh data and add analytics (Corsi, TOI, shifts) β€” no key required.
  • Full reference: the PWHL β†’ Loaders and Additional functions pages in the sidebar.
  • Junior & minor hockey? The same HockeyTech surface powers the AHL / OHL / WHL / QMJHL β€” see 11_junior_hockey_intro.ipynb.
  • The men's game and the modern NHL APIs live in 07_nhl_intro.ipynb.
  • R user? The same data lives in fastRhockey (NHL + PWHL).

Now go tell the story of the PWHL β€” the data's all here. πŸ’πŸ’œ