On this page
Updated 2026-10-12: the 2026-27 season, 4-7 games in per team. Early-season tables move a lot from week to week.
This page is rebuilt every week with the site. It builds the standings, charts goal differential with logos and ranks the points leaders with their headshots, for the latest NHL season with games: the season to date from October to April, the final regular season once it is over. Data: fastRhockey’s box-score releases and the NHL’s api-web.nhle.com standings.
Picking the season
NHL seasons are named by the year they end (2025-26 is
2026) and start in the fall, so until September the
calendar points at the season that ended in June. For a season with no
file yet fastRhockey::load_nhl_team_box() warns and returns
no rows, and a new season’s file holds only preseason games at first, so
the helper treats “no regular-season games” as “not published” and the
page steps back one season. A game id’s fifth and sixth digits are its
type: 02 is the regular season, 03 the playoffs. During the season the
standings come from api-web.nhle.com as of today; once the regular
season is over, as of its last day. That call goes through
live(): if the API does not answer, the page shows a note
in place of the standings and the goal-differential chart for that week
instead of failing the site build.
library(sdvplotR)
library(dplyr, warn.conflicts = FALSE)
library(ggplot2)
library(gt)
today <- Sys.Date()
day_label <- function(day, month = "%b") sub(" 0", " ", format(as.Date(day), paste(month, "%d"))) # "Oct 5"
# fastRhockey names a season by the year it ends (2025-26 is 2026); it starts in
# the fall, so until September the calendar points at the season that ended in June
current <- as.integer(format(today, "%Y")) + (format(today, "%m") >= "09")
label <- function(season) sprintf("%d-%02d", season - 1, season %% 100)
# an NHL game id's fifth and sixth digits are its type: 01 preseason, 02 regular season, 03 playoffs
game_type <- function(game_id) (as.numeric(game_id) %/% 10000) %% 100
# A season's team box scores, or NULL when it has no regular-season games yet:
# load_nhl_team_box() warns and returns no rows for a season it has no file for,
# and a new season's file holds only preseason games at first
team_games <- function(season) {
box <- tryCatch(suppressWarnings(fastRhockey::load_nhl_team_box(seasons = season)), error = function(e) NULL)
if (NROW(box) == 0 || !any(game_type(box$game_id) == 2)) NULL else box
}
season <- current
box <- team_games(season)
if (is.null(box)) {
season <- current - 1
box <- team_games(season)
}
stopifnot(!is.null(box))
box <- mutate(box, game_date = as.Date(game_date), type = game_type(game_id))
last_regular <- max(box$game_date[box$type == 2])
playoffs <- filter(box, type == 3)
in_season <- season == current && nrow(playoffs) == 0
# games per team from the release file (they differ early on)
games_in <- paste(unique(range(table(box$team_abbrev[box$type == 2]))), collapse = "-")
# A live API call, or NULL when the API does not answer: one bad week then shows
# a note in place of a table instead of failing the whole site build
live <- function(expr) tryCatch(expr, error = function(e) NULL)
unavailable <- function(what, ok) {
if (ok) "" else sprintf("*%s were unavailable when this page was built on %s; they return next week.*", what, today)
}
# api-web.nhle.com standings: as of today in season, as of the last regular-season day after it
standings <- live(fastRhockey::nhl_standings(date = if (in_season) NULL else format(last_regular)))
standings_ok <- NROW(standings) > 0
if (in_season) {
status <- sprintf(
"the %s season, %s games in per team. Early-season tables move a lot from week to week.",
label(season), games_in
)
through <- sprintf("through %s (%s games in)", day_label(last_regular), games_in)
} else if (season < current) {
status <- sprintf(
"the offseason, so this is the final %s regular season; the %s season has no regular-season games yet.",
label(season), label(current)
)
through <- "final regular season"
} else if (as.numeric(today - max(playoffs$game_date)) <= 10) {
status <- sprintf("the final %s regular season; the playoffs are under way.", label(season))
through <- "final regular season"
} else {
status <- sprintf("the offseason, so this is the final %s regular season.", label(season))
through <- "final regular season"
}1. Standings
Grouped by division in the NHL’s own order.
gt_sdv_logos() reads the NHL’s three-letter team codes
(LAK, NJD, TBL) through
sdvplotR’s aliases, so nothing is mapped by hand.
standings |>
arrange(division_name, division_sequence) |>
transmute(
division = division_name,
logo = team_abbr,
team = team_common_name,
gp = games_played, w = wins, l = losses, otl = ot_losses,
pts = points, pts_pct = point_pctg,
gf = goals_for, ga = goals_against, diff = goal_differential,
l10 = paste(l10_wins, l10_losses, l10_ot_losses, sep = "-"),
streak = paste0(streak_code, streak_count)
) |>
gt(groupname_col = "division", id = "nhl-standings") |>
tab_header(
title = paste("NHL standings,", label(season)),
subtitle = paste0(toupper(substr(through, 1, 1)), substring(through, 2))
) |>
fmt_number(pts_pct, decimals = 3) |>
fmt_number(diff, decimals = 0, force_sign = TRUE) |>
cols_label(
logo = "", team = "Team", gp = "GP", w = "W", l = "L", otl = "OTL", pts = "PTS",
pts_pct = "PTS%", gf = "GF", ga = "GA", diff = "DIFF", l10 = "Last 10", streak = "Streak"
) |>
tab_source_note("Data: api-web.nhle.com via fastRhockey | Viz: sdvplotR") |>
gt_sdv_logos(columns = logo, league = "nhl", height = 24) |>
gt_theme_swiss()| NHL standings, 2026-27 | ||||||||||||
| Through Oct 11 (4-7 games in) | ||||||||||||
| Team | GP | W | L | OTL | PTS | PTS% | GF | GA | DIFF | Last 10 | Streak | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Atlantic | ||||||||||||
![]() |
Lightning | 5 | 4 | 1 | 0 | 8 | 0.800 | 16 | 12 | +4 | 4-1-0 | W4 |
![]() |
Senators | 5 | 4 | 1 | 0 | 8 | 0.800 | 15 | 11 | +4 | 4-1-0 | W2 |
![]() |
Bruins | 6 | 4 | 2 | 0 | 8 | 0.667 | 20 | 13 | +7 | 4-2-0 | W2 |
![]() |
Maple Leafs | 6 | 3 | 2 | 1 | 7 | 0.583 | 20 | 18 | +2 | 3-2-1 | W1 |
![]() |
Sabres | 6 | 3 | 3 | 0 | 6 | 0.500 | 16 | 22 | −6 | 3-3-0 | W1 |
![]() |
Panthers | 6 | 2 | 2 | 2 | 6 | 0.500 | 12 | 17 | −5 | 2-2-2 | L2 |
![]() |
Canadiens | 5 | 2 | 2 | 1 | 5 | 0.500 | 15 | 19 | −4 | 2-2-1 | W1 |
![]() |
Red Wings | 5 | 1 | 3 | 1 | 3 | 0.300 | 10 | 15 | −5 | 1-3-1 | OT1 |
| Central | ||||||||||||
![]() |
Mammoth | 6 | 4 | 2 | 0 | 8 | 0.667 | 22 | 15 | +7 | 4-2-0 | W1 |
![]() |
Wild | 5 | 3 | 1 | 1 | 7 | 0.700 | 15 | 9 | +6 | 3-1-1 | W1 |
![]() |
Jets | 5 | 3 | 1 | 1 | 7 | 0.700 | 13 | 15 | −2 | 3-1-1 | L1 |
![]() |
Avalanche | 5 | 3 | 2 | 0 | 6 | 0.600 | 26 | 17 | +9 | 3-2-0 | L1 |
![]() |
Predators | 5 | 2 | 1 | 2 | 6 | 0.600 | 14 | 14 | 0 | 2-1-2 | OT1 |
![]() |
Stars | 5 | 2 | 2 | 1 | 5 | 0.500 | 11 | 8 | +3 | 2-2-1 | OT1 |
![]() |
Blues | 5 | 2 | 2 | 1 | 5 | 0.500 | 14 | 16 | −2 | 2-2-1 | W1 |
![]() |
Blackhawks | 6 | 1 | 5 | 0 | 2 | 0.167 | 12 | 26 | −14 | 1-5-0 | L2 |
| Metropolitan | ||||||||||||
![]() |
Hurricanes | 7 | 5 | 1 | 1 | 11 | 0.786 | 27 | 17 | +10 | 5-1-1 | W5 |
![]() |
Rangers | 7 | 5 | 2 | 0 | 10 | 0.714 | 23 | 13 | +10 | 5-2-0 | W1 |
![]() |
Penguins | 6 | 3 | 2 | 1 | 7 | 0.583 | 22 | 17 | +5 | 3-2-1 | W1 |
![]() |
Capitals | 5 | 3 | 2 | 0 | 6 | 0.600 | 17 | 14 | +3 | 3-2-0 | L1 |
![]() |
Devils | 4 | 2 | 2 | 0 | 4 | 0.500 | 10 | 14 | −4 | 2-2-0 | W1 |
![]() |
Blue Jackets | 4 | 2 | 2 | 0 | 4 | 0.500 | 13 | 14 | −1 | 2-2-0 | L1 |
![]() |
Islanders | 5 | 2 | 3 | 0 | 4 | 0.400 | 16 | 13 | +3 | 2-3-0 | L1 |
![]() |
Flyers | 7 | 0 | 5 | 2 | 2 | 0.143 | 8 | 28 | −20 | 0-5-2 | L4 |
| Pacific | ||||||||||||
![]() |
Golden Knights | 6 | 5 | 1 | 0 | 10 | 0.833 | 23 | 14 | +9 | 5-1-0 | W4 |
![]() |
Oilers | 5 | 4 | 0 | 1 | 9 | 0.900 | 25 | 17 | +8 | 4-0-1 | W4 |
![]() |
Ducks | 5 | 4 | 1 | 0 | 8 | 0.800 | 18 | 13 | +5 | 4-1-0 | W2 |
![]() |
Kraken | 6 | 4 | 2 | 0 | 8 | 0.667 | 26 | 17 | +9 | 4-2-0 | W2 |
![]() |
Sharks | 5 | 3 | 2 | 0 | 6 | 0.600 | 13 | 17 | −4 | 3-2-0 | L1 |
![]() |
Canucks | 7 | 2 | 5 | 0 | 4 | 0.286 | 24 | 35 | −11 | 2-5-0 | L4 |
![]() |
Kings | 4 | 0 | 2 | 2 | 2 | 0.250 | 10 | 17 | −7 | 0-2-2 | OT1 |
![]() |
Flames | 5 | 0 | 5 | 0 | 0 | 0.000 | 8 | 27 | −19 | 0-5-0 | L5 |
| Data: api-web.nhle.com via fastRhockey | Viz: sdvplotR | ||||||||||||
2. Goal differential
Goals for minus goals against, all 32 teams, in team colors;
scale_x_sdv() and theme_x_sdv() swap the team
codes on the x axis for logos.
gd <- standings |>
mutate(team = sdv_resolve(team_abbr, league = "nhl")) |>
arrange(desc(goal_differential), team) # the team breaks a tie, so an unchanged week renders the same
ggplot(gd, aes(x = factor(team, levels = team), y = goal_differential, fill = team)) +
geom_col(width = 0.75) +
geom_hline(yintercept = 0, colour = "grey20", linewidth = 0.4) +
scale_fill_sdv(league = "nhl", guide = "none") +
scale_x_sdv(league = "nhl", size = 20) +
labs(
title = paste("NHL goal differential,", label(season), through),
subtitle = "Goals for minus goals against",
x = NULL, y = "Goal differential",
caption = "Data: api-web.nhle.com via fastRhockey | Viz: sdvplotR"
) +
theme_minimal(base_size = 13) +
theme(
plot.title = element_text(face = "bold"),
panel.grid.major.x = element_blank(),
panel.grid.minor = element_blank()
) +
theme_x_sdv()
3. Points leaders with headshots
Goals and assists from the regular-season skater box scores, stacked
into points. The box scores carry NHL API player ids, which
geom_sdv_headshots(id_system = "league") turns into the
NHL’s own headshots; the game rosters give the full names.
skaters <- fastRhockey::load_nhl_player_box(seasons = season) |>
filter(game_type(game_id) == 2)
full_names <- fastRhockey::load_nhl_game_rosters(seasons = season) |>
distinct(player_id, full_name) |>
filter(!duplicated(player_id))
stopifnot(identical(class(skaters$player_id), class(full_names$player_id)))
leaders <- skaters |>
arrange(game_date, game_id) |>
group_by(player_id) |>
summarise(
team = last(team_abbrev), gp = n(),
goals = sum(goals), assists = sum(assists), points = sum(points),
.groups = "drop"
) |>
arrange(desc(points), desc(goals), player_id) |>
slice_head(n = 10) |>
left_join(full_names, by = "player_id") |>
mutate(full_name = factor(full_name, levels = rev(full_name)))
top <- max(leaders$points)
bars <- tidyr::pivot_longer(leaders, c(goals, assists), names_to = "part", values_to = "n") |>
mutate(part = factor(part, levels = c("assists", "goals"), labels = c("Assists", "Goals")))
ggplot(leaders, aes(y = full_name)) +
geom_col(data = bars, aes(x = n, fill = part), width = 0.7) +
geom_sdv_headshots(
aes(x = -0.07 * top, player_id = player_id),
league = "nhl", id_system = "league", height = 0.085
) +
geom_sdv_logos(aes(x = points + 0.06 * top, team = team), league = "nhl", height = 0.06) +
geom_text(aes(x = points + 0.12 * top, label = points), hjust = 0, fontface = "bold", size = 3.8) +
scale_fill_manual(values = c(Goals = "#1f3b73", Assists = "#9fb4d8"), breaks = c("Goals", "Assists")) +
scale_x_continuous(limits = c(-0.14 * top, 1.22 * top), expand = c(0, 0)) +
labs(
title = paste("NHL points leaders,", label(season)),
# the box-score release can trail the live standings by a day or two
subtitle = paste0(
"Goals and assists, regular season",
if (in_season) paste(", games through", day_label(max(skaters$game_date))) else ""
),
x = "Points", y = NULL, fill = NULL,
caption = "Data: fastRhockey box scores | Viz: sdvplotR"
) +
theme_minimal(base_size = 13) +
theme(
plot.title = element_text(face = "bold"),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
legend.position = "top", legend.justification = "left"
)
4. Interactive version
The goal differential bars from section 2 as a plotly chart: hover a
bar for the team’s record and its goals for and against. plotly draws no
logos on its own, so sdv_axis_logos() hides the team codes
and puts a logo under each bar.
plotly::plot_ly(
gd, x = ~factor(team, levels = team), y = ~goal_differential, type = "bar",
marker = list(color = unname(sdv_team_colors("nhl", gd$team))),
hoverinfo = "text", textposition = "none",
text = ~sprintf(
"%s, %d-%d-%d<br>%d goals for, %d against (%+d)",
team_common_name, wins, losses, ot_losses, goals_for, goals_against, goal_differential
)
) |>
plotly::layout(
title = list(text = paste("NHL goal differential,", label(season)), x = 0.02),
xaxis = list(title = ""), yaxis = list(title = "Goal differential"),
margin = list(b = 50, t = 50)
) |>
sdv_axis_logos(gd$team, "nhl")More recipes like this one, in plotly, ggiraph, leaflet and reactable, are in Interactive charts.
Related
- NHL walks through fastRhockey and sdvplotR more broadly.
- MLB builds the same three views for baseball.
- Social graphics, automated posts leaderboards like these on a schedule.
Related SportsDataverse packages
- sdvplot - the Python plotting package
- sdvplot-js - the JavaScript / TypeScript plotting packages
- cfbfastR - college football
- hoopR - men’s basketball
- wehoop - women’s basketball
- baseballr - baseball
- fastRhockey - hockey
- oddsapiR - betting odds
- sportyR - playing surfaces
- sportsdataverse-py - the Python package
- sportsdataverse-R - the R meta-package
































