Skip to main content
Version: 0.0.70

πŸ’ 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
(10456, 95)
(pbp
.group_by('event')
.agg(pl.len().alias('events'))
.sort('events', descending=True))
shape: (4, 2)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ event ┆ events β”‚
β”‚ --- ┆ --- β”‚
β”‚ str ┆ u32 β”‚
β•žβ•β•β•β•β•β•β•β•β•β•ͺ════════║
β”‚ shot ┆ 4922 β”‚
β”‚ faceoff ┆ 4631 β”‚
β”‚ penalty ┆ 518 β”‚
β”‚ goal ┆ 385 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜

🍳 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 β”‚
β”‚ New York ┆ 9 β”‚
β”‚ Ottawa ┆ 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 β”‚
β”‚ Taylor ┆ Heise ┆ 9 ┆ 12 ┆ 21 β”‚
β”‚ Ella ┆ Shelton ┆ 7 ┆ 14 ┆ 21 β”‚
β”‚ Emma ┆ Maltais ┆ 5 ┆ 16 ┆ 21 β”‚
β”‚ Erin ┆ Ambrose ┆ 4 ┆ 16 ┆ 20 β”‚
β”‚ Brianne ┆ Jenner ┆ 9 ┆ 11 ┆ 20 β”‚
β”‚ Grace ┆ Zumwinkle ┆ 12 ┆ 8 ┆ 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 β”‚
β”‚ Cross Checking ┆ 26 β”‚
β”‚ Holding ┆ 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 β”‚
β”‚ Gabbie ┆ Hughes ┆ 20 ┆ 10 β”‚
β”‚ Allie ┆ Munroe ┆ 20 ┆ 10 β”‚
β”‚ Renata ┆ Fast ┆ 18 ┆ 9 β”‚
β”‚ Sarah ┆ Nurse ┆ 18 ┆ 9 β”‚
β”‚ Emma ┆ Maltais ┆ 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 β”‚
β”‚ Marie-Philip ┆ Poulin ┆ 11 β”‚
β”‚ Sarah ┆ Nurse ┆ 11 β”‚
β”‚ Laura ┆ Stacey ┆ 10 β”‚
β”‚ Daryl ┆ Watts ┆ 10 β”‚
β”‚ Taylor ┆ Heise ┆ 9 β”‚
β”‚ Brianne ┆ Jenner ┆ 9 β”‚
β”‚ Gabbie ┆ Hughes ┆ 9 β”‚
β”‚ Michela ┆ Cava ┆ 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 β”‚
β”‚ Nicole ┆ Hensley ┆ 4 β”‚
β”‚ Kristen ┆ Campbell ┆ 4 β”‚
β”‚ Alex ┆ Carpenter ┆ 3 β”‚
β”‚ Gabbie ┆ Hughes ┆ 3 β”‚
β”‚ Sarah ┆ Nurse ┆ 3 β”‚
β”‚ Hilary ┆ Knight ┆ 3 β”‚
β”‚ Marie-Philip ┆ Poulin ┆ 3 β”‚
β”‚ Susanna ┆ Tapani ┆ 3 β”‚
β”‚ Jade ┆ Downie-Landry ┆ 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 ┆ 18 ┆ 5 ┆ 84.155844 β”‚
β”‚ 76 ┆ 20 ┆ 5 ┆ 78.26087 β”‚
β”‚ 89 ┆ 17 ┆ 7 ┆ 72.943981 β”‚
β”‚ 100 ┆ 19 ┆ 10 ┆ 66.86217 β”‚
β”‚ 20 ┆ 20 ┆ 17 ┆ 64.228368 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

✨ 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

πŸŽ‰ 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. πŸ’πŸ’œ