Build a Standings Table from a Season of Game Results

BasketballIntermediatePython~5 min read

What you'll build

A full W-L standings table aggregated from a game-by-game results log.

A full W-L standings table aggregated from a game-by-game results log.
Data: Bundled sample (NBA game results), retrieved June 2026

The standings are an output, not an input. Underneath every win-loss table is a long, plain log of individual games — who played whom and who scored what — and turning that log into a standings table is one of the most common things you'll do with sports data. Take a full season of game results and you can collapse it into wins, losses, and win percentage with a single groupby. But there's a small reshape in front of that groupby that most beginners miss — every game row touches two teams — and once you see how to handle it you'll reach for the move everywhere.

This builds on Group, Pivot, Reshape. The data is the bundled nba_home_results.csv — a real season of game results (date, home/away team, and each side's points), retrieved from Basketball-Reference — so it runs offline.

  1. Look at the game log

    Each row is one game and touches two teams — one home, one away. That's the wrinkle: to count a team's results we need to see it whether it was home or away.

    python
    import pandas as pd
    
    games = pd.read_csv("nba_home_results.csv")
    # columns: date, away_team, away_pts, home_team, home_pts

    If we only ever looked at the home_team column we'd capture half of each team's season and miss the other half. So first we reshape.

  2. One row per team, per game

    We build the log twice — once from the home team's point of view, once from the away team's — renaming each side's points to neutral pf (points for) and pa (points against). Stack them and every team now has one row for every game it played.

    python
    home = games.rename(columns={"home_team": "team", "home_pts": "pf", "away_pts": "pa"})[["team", "pf", "pa"]]
    away = games.rename(columns={"away_team": "team", "away_pts": "pf", "home_pts": "pa"})[["team", "pf", "pa"]]
    long = pd.concat([home, away], ignore_index=True)
    long["win"] = (long["pf"] > long["pa"]).astype(int)

    The win column is a boolean (pf > pa) turned into 1 or 0 with .astype(int) — which makes the next step trivial, because summing a column of 1s and 0s just counts the wins.

  3. One groupby builds the table

    Group by team, sum the wins, count the games, and the standings fall out.

    python
    standings = (long.groupby("team")
                 .agg(W=("win", "sum"), G=("win", "count"))
                 .reset_index())
    standings["L"] = standings["G"] - standings["W"]
    standings["WinPct"] = (standings["W"] / standings["G"]).round(3)
    standings = standings.sort_values("W", ascending=False)
    print(standings[["team", "W", "L", "WinPct"]].head(8).to_string())
    Standings, rebuilt from the game log
    Reconstructed standings from 1231 games:
                          team   W   L  WinPct
    1           Boston Celtics  64  18   0.780
    7           Denver Nuggets  57  25   0.695
    20   Oklahoma City Thunder  57  25   0.695
    17  Minnesota Timberwolves  56  26   0.683
    12    Los Angeles Clippers  51  31   0.622
    6         Dallas Mavericks  50  32   0.610
    19         New York Knicks  50  32   0.610
    16         Milwaukee Bucks  49  33   0.598

    From a flat list of more than a thousand games, two lines produced the league table — the Celtics on top at 64–18 (.780), with Denver and Oklahoma City tied behind them. No game was counted twice and none was missed, because the home-and-away reshape gave every team a complete season.

  4. Chart the standings

    python
    import matplotlib.pyplot as plt
    
    ordered = standings.sort_values("W")   # ascending so the best record lands on top
    fig, ax = plt.subplots(figsize=(8, 9))
    ax.barh(ordered["team"], ordered["W"])
    ax.set_xlabel("wins")
    fig.savefig("wins_bar.png", dpi=144, bbox_inches="tight")
    Horizontal bar chart of every NBA team's win total for the season, reconstructed from the game log, sorted with the best record on top
    Data: Bundled sample (NBA game results), retrieved June 2026

    The same standings, now ranked at a glance. Everything in this chart was computed from raw scores — we never needed a pre-made standings table at all.

Troubleshooting

Every team's win total is about half what it should be

You aggregated only the home (or only the away) rows. Each team plays home and away, so you must build both perspectives and concat them before grouping — that's the whole point of the reshape step.

Some team names appear twice in the standings

The same franchise is spelled inconsistently in the log (an abbreviation in some rows, a full name in others, or a stray space). groupby treats those as different teams. Standardize the names first — see Cleaning Messy Sports Data.

What about tie games?

Basketball has no ties, so pf > pa is safe here. In a sport that allows draws (soccer, hockey regulation), add a third case — e.g., assign 1/0/0.5 or count wins, draws, and losses separately — rather than forcing every game into win-or-loss.

Challenge yourself

Extend the aggregation to add points for and against: in the same .agg, sum pf and pa, then compute a points differential column and sort by it. Does the differential order match the win order? Then split the season into home and away records (group by team and by whether the row came from the home frame) to see who was a genuine road warrior.

Get the code

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

Download the finished script (45_build_a_standings_table_from_results.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, sdt_nba.py.

More Basketball tutorials

A current-standings DataFrame from nba_api, with the proper headers baked in.
Basketball Beginner

Pull Your First NBA Data with nba_api

Pull NBA standings with nba_api, with the browser headers and retry logic stats.nba.com demands. Includes exactly what to do when the endpoint refuses to answer.

~9 min
A ranked net-rating table styled like a real dashboard, exported as an image.
Basketball Intermediate

Build a Team Net-Rating Dashboard Table

Combine offensive and defensive ratings into a ranked net-rating table, then style it into a dashboard-quality figure you can drop into a report.

~8 min
A half-court drawn in matplotlib with a player's makes and misses plotted on it.
Basketball Intermediate

Draw an NBA Shot Chart with matplotlib

Draw a regulation half-court from scratch in matplotlib, then plot a player's makes and misses in court coordinates for a real, shareable shot chart.

~10 min