Track a Team's Form: Rolling Goal Differential

HockeyIntermediatePython~9 min read

What you'll build

A team's game-by-game form as a rolling goal differential line.

A team's game-by-game form as a rolling goal differential line.
Data: NHL public API (api-web.nhle.com), retrieved June 2026

"Form" is the word fans use for a team that's hot or cold right now — and it turns out you can compute it, not just feel it. Pull an entire NHL season of one team's games with scores, then smooth their goal differential over a rolling ten-game window, and a jagged game-by-game line resolves into a readable wave of momentum. We'll do it with the Edmonton Oilers and the NHL's free public API; the payoff is a chart where every hot streak and slump is visible at a glance.

This builds on your first NHL data pull, so you already know how to open a polite session and read JSON from api-web.nhle.com. The new idea is the rolling window - one of the most useful moves in all of time-series analysis. The data comes from the NHL public API (api-web.nhle.com), retrieved June 2026.

  1. Pull the full season schedule

    The NHL exposes a whole team's season at one endpoint: club-schedule-season/<TEAM>/<SEASON>. We open a polite session - the same well-mannered HTTP wrapper from the intro tutorial, with browser headers and automatic retries - and request Edmonton's 2024-2025 season. The team code is the three-letter abbreviation EDM, and the season is the two years stuck together as 20242025.

    python
    import matplotlib.pyplot as plt
    import pandas as pd
    
    TEAM, SEASON = "EDM", "20242025"
    session = sdt.polite_session(referer="https://www.nhl.com/")
    sched = session.get(
        f"https://api-web.nhle.com/v1/club-schedule-season/{TEAM}/{SEASON}",
        timeout=30).json()

    The response's "games" key holds a list of game objects, one per fixture - preseason, regular season, and any that haven't been played yet. Each one carries a homeTeam and an awayTeam object with scores once the game is final. Our job is to walk that list and pull out, for every completed game, how many goals Edmonton scored and conceded.

  2. Walk the games and record goals for and against

    We loop over the games and keep only finished ones - the API marks those with a gameState of "OFF" or "FINAL". We also keep only regular-season games: NHL game ids encode the type in digits 5-6, where "02" means regular season (preseason is "01", playoffs "03"), so we skip the exhibitions that would otherwise pollute our form line. For each remaining game we figure out which side is Edmonton: if the home team's abbreviation is our TEAM, then "us" is the home object and "them" is away; otherwise it's the other way around. Then we record the date, our goals (gf), and their goals (ga). A guard skips any game that somehow has no score.

    python
    rows = []
    for g in sched["games"]:
        if g.get("gameState") not in ("OFF", "FINAL"):
            continue
        if str(g["id"])[4:6] != "02":      # regular season only
            continue
        home, away = g["homeTeam"], g["awayTeam"]
        us, them = (home, away) if home["abbrev"] == TEAM else (away, home)
        if us.get("score") is None or them.get("score") is None:
            continue
        rows.append({"date": g["gameDate"], "gf": us["score"], "ga": them["score"]})

    That home-or-away swap is the crux of the whole script. Goal differential only means something from one team's point of view, so we always orient "goals for" and "goals against" to Edmonton no matter which side of the fixture they were on. Using .get(...) instead of [...] for the optional fields returns None rather than raising a KeyError when a key is absent - a small habit that makes messy real-world JSON safe to walk.

  3. Build the game log and the rolling average

    We turn the list of dictionaries into a DataFrame, sort it by date so the games are in chronological order, and reset the index so it reads as a clean count. Then come the three columns that matter: a game number 1..N, the per-game diff (goals for minus goals against), and the star of the show - roll10, the average of diff over the last ten games.

    python
    gl = pd.DataFrame(rows).sort_values("date").reset_index(drop=True)
    gl["game"] = range(1, len(gl) + 1)
    gl["diff"] = gl["gf"] - gl["ga"]
    gl["roll10"] = gl["diff"].rolling(10).mean()

    rolling(10) creates a sliding window ten games wide; .mean() averages the differential inside it, then the window slides forward one game and does it again. A single game's differential is noisy - a 6-1 win and a 1-2 loss swing wildly - but averaged over ten, the line settles into the team's true recent form. This is exactly the smoothing technique behind every "last 10 games" stat you've ever seen on a broadcast.

  4. Look at the game log

    Let's print the season's size and the first few rows so we can see the columns line up.

    python
    print(f"{TEAM}, {SEASON[:4]}-{SEASON[4:]}: {len(gl)} games")
    sdt.show_df(gl[["game", "date", "gf", "ga", "diff", "roll10"]], n=6)
    Edmonton's game log (first rows)
    EDM, 2024-2025: 82 games
       game        date  gf  ga  diff  roll10
    0     1  2024-10-09   0   6    -6     NaN
    1     2  2024-10-12   2   5    -3     NaN
    2     3  2024-10-13   1   4    -3     NaN
    3     4  2024-10-15   4   3     1     NaN
    4     5  2024-10-17   4   2     2     NaN
    5     6  2024-10-19   1   4    -3     NaN

    This is the most important thing to understand about a rolling window, and the output shows it plainly: roll10 is NaN for the first several games. That's not a bug - a ten-game average simply doesn't exist until ten games have been played, so pandas fills those early rows with "not a number" and the real averages begin at game 10. The raw per-game diff column already tells a story of an ugly start: Edmonton open with a 6-0 drubbing (diff -6) and lose three of their first four, before a pair of wins (4-3 and 4-2) briefly steadies things. That early volatility is exactly why we smooth - and why the rolling line won't even start until enough games have stacked up. The header also confirms the pull caught the full regular season of 82 games.

  5. Chart the form as a filled wave

    Now we draw the rolling line so the streaks read instantly. We add a horizontal line at zero - the break-even point where a team scores exactly as many as it allows - then plot roll10 against the game number. The flourish that makes it pop is fill_between: we shade the area above zero in hockey blue (good form) and below zero in red (bad form), each only where the line is on that side, using the where= argument.

    python
    fig, ax = plt.subplots(figsize=(8.6, 4.6))
    ax.axhline(0, color="#C2B7A1", linewidth=1)
    ax.plot(gl["game"], gl["roll10"], color="#2C5E8A", linewidth=2)
    ax.fill_between(gl["game"], gl["roll10"], 0, where=gl["roll10"] >= 0,
                    color="#2C5E8A", alpha=0.15)
    ax.fill_between(gl["game"], gl["roll10"], 0, where=gl["roll10"] < 0,
                    color="#B23A3A", alpha=0.15)
    ax.set_title(f"{TEAM} form: 10-game rolling goal differential, "
                 f"{SEASON[:4]}-{SEASON[4:]}")
    ax.set_xlabel("game of the season")
    ax.set_ylabel("avg goal differential (last 10 games)")
    fig.savefig("rolling_goal_diff.png", dpi=144, bbox_inches="tight")
    Line chart of Edmonton's 10-game rolling goal differential across the 2024-2025 season, shaded blue above the zero line for hot stretches and red below for cold ones
    Data: NHL public API (api-web.nhle.com), retrieved June 2026

    The zero line is the spine of the chart: every stretch where the blue wave rides above it is a run where Edmonton outscored opponents over their last ten, and every dip into red is a cold spell. Notice the line is blank for the first nine games before the rolling average kicks in - the visual echo of those NaN values we just discussed. Read left to right and you're reading the season's momentum: the peaks are the hot streaks, the troughs are the slumps, and the height of each tells you how dominant or dire the team was at that moment. The source credit for the NHL public API rides along in the figure footer automatically.

Troubleshooting

The roll10 column is all NaN or mostly empty

The first nine values being NaN is correct - a ten-game window needs ten games first. But if every value is NaN, you have fewer than ten completed games (an early-season or offseason pull), or the rows didn't sort by date. Confirm len(gl) is comfortably above ten and that sort_values("date") ran before the rolling call.

KeyError: 'games' from the response

The endpoint or season string is off. Make sure the URL is exactly /v1/club-schedule-season/EDM/20242025 - the season is two four-digit years concatenated, with no dash. A wrong team code or a season the API doesn't have yet returns a different shape with no "games" key.

Goal differential looks backwards (good team trends negative)

The home/away orientation is flipped. The line us, them = (home, away) if home["abbrev"] == TEAM else (away, home) must put your team into us. If you hard-coded us = home, then every away game counts the opponent's goals as yours. Print a couple of rows and sanity-check a game you remember.

The request hangs or times out

We pass timeout=30 so a request can't hang forever, and the polite session retries flaky status codes with backoff. If it still fails you may be offline or rate-limited - wait a moment and re-run rather than firing repeated requests by hand.

Challenge yourself

Change the window: try rolling(5) for a twitchier line that reacts to short hot streaks, and rolling(20) for a calmer one that shows only the big seasonal trends - the right window is a judgement call, and seeing them side by side teaches you the trade-off. Then overlay a second team: pull another club code (say COL for Colorado) the same way and plot both rolling lines on one axis to compare their seasons' shapes. For a finishing touch, extend the id filter to include the playoffs (game type "03") and mark where the regular season ends with a vertical line, so a single chart shows a team's form carrying into the postseason.

Get the code

Here's the complete, working script for this tutorial. It runs exactly as shown.

Download the finished script (36_track_a_team_form_rolling_goal_differential.py)

This script imports a small shared helper (and reads any bundled sample data) that live next to it in /downloads/ — grab these into the same folder so it runs as-is: sdt_common.py.

More Hockey tutorials

One team's shot attempts plotted on a drawn rink from real play-by-play.
Hockey Intermediate

Build a Shot-Location Plot for an NHL Team

Pull a game's play-by-play from the NHL API, draw a rink in matplotlib, and plot one team's shot attempts in rink coordinates to see where they attack from.

~8 min
A clean leaderboard of the season's top goal scorers.
Hockey Intermediate

Build an NHL Goal-Scoring Leaders Chart

Use the NHL's stats-leaders endpoint to pull the top goal scorers, shape the nested JSON into a tidy table, and build a leaderboard chart with each player's team.

~8 min