Build a Team Net-Rating Dashboard Table

BasketballIntermediatePython~8 min read

What you'll build

A ranked net-rating table styled like a real dashboard, exported as an image.

A ranked net-rating table styled like a real dashboard, exported as an image.
Data: stats.nba.com via nba_api, retrieved June 2026

Net rating is the one number I'd keep if I could keep only one to judge an NBA team. It folds offense and defense into a single figure - and it predicts who's actually good far better than a won-lost record, which is noisy and schedule-dependent. So we'll go get it: every team's advanced ratings for 2023-24, ranked into a clean dashboard table, then a diverging bar chart that splits the league into the good, the bad, and the merely average.

This builds on your first NBA pull - same headers, same fallback pattern, a different endpoint. If you haven't set up nba_api yet, start there; here we assume it's installed and reuse the shared NBA_HEADERS.

As with every NBA tutorial, the live pull below works on your home connection but times out from data-center and VPN IPs, so this page was built from a bundled real sample - team ratings from Basketball-Reference. The numbers are genuine; on your machine the same code fetches them live.

  1. What net rating actually measures

    Net rating answers a clean question: per 100 possessions, how many more points does a team score than it allows? It's built from two halves. Offensive rating (ORtg) is points scored per 100 possessions; defensive rating (DRtg) is points allowed per 100 possessions. Net rating is simply the difference:

    python
    net_rating = off_rating - def_rating  # ORtg - DRtg

    Why "per 100 possessions" instead of "per game"? Because teams play at different paces. A fast team and a slow team can score wildly different raw point totals while being equally efficient. Normalizing to possessions strips pace out, so a +6 net rating means the same thing for the run-and-gun Pacers as it does for a grind-it-out defense. A positive number means you outscore opponents; a negative number means you're getting outscored.

  2. Pull advanced team stats

    Basic box-score endpoints won't give you ratings - you need the advanced measure type. The endpoint is LeagueDashTeamStats, and the key argument is measure_type_detailed_defense="Advanced", which switches the response from raw totals to the efficiency metrics we want. As always, we pass our browser headers and a timeout.

    python
    from nba_api.stats.endpoints import leaguedashteamstats
    
    s = leaguedashteamstats.LeagueDashTeamStats(
        season="2023-24", measure_type_detailed_defense="Advanced",
        headers=NBA_HEADERS, timeout=30)
    df = s.get_data_frames()[0]
    
    ratings = pd.DataFrame({
        "Team": df["TEAM_NAME"], "ORtg": df["OFF_RATING"],
        "DRtg": df["DEF_RATING"], "NRtg": df["NET_RATING"],
    })

    The advanced response already includes NET_RATING, so we don't have to compute it - but it's exactly OFF_RATING - DEF_RATING, and it's a good habit to verify that yourself once with (ratings["ORtg"] - ratings["DRtg"]).round(1). The numbers should match the NRtg column to a rounding error.

  3. Wrap it with the live-or-bundled fallback

    Same defensive structure as before: try the live call, and if the server blocks us, load the bundled real ratings instead. The helper retries with exponential backoff and returns a source label so the chart can credit its data honestly.

    python
    def bundled_loader():
        return sdt_nba.bundled("nba_ratings.csv")[["Team", "ORtg", "DRtg", "NRtg"]]
    
    ratings, source = sdt_nba.live_or_bundled(
        live_call, bundled_loader, "advanced team stats")
    ratings = ratings.sort_values("NRtg", ascending=False).reset_index(drop=True)

    Sorting by NRtg descending puts the best team first. The reset_index(drop=True) renumbers the rows cleanly so we can build a 1-based rank in the next step. If you're running at home, live_call never fails and the bundled loader is simply never reached.

  4. Build the ranked dashboard table

    Now we turn the DataFrame into something that reads like a real leaderboard: a 1-based rank index, every column rounded to one decimal, the whole league from best to worst.

    python
    show = ratings.round(1).copy()
    show.index = range(1, len(show) + 1)
    print("Team net-rating table, 2023-24 (best to worst):")
    print(show.to_string())
    Full net-rating table, 2023-24
    Team net-rating table, 2023-24 (best to worst):
                          Team   ORtg   DRtg  NRtg
    1           Boston Celtics  124.2  112.5  11.7
    2    Oklahoma City Thunder  120.4  113.1   7.3
    3   Minnesota Timberwolves  116.6  110.0   6.6
    4           Denver Nuggets  119.5  114.0   5.4
    5          New York Knicks  119.1  114.2   4.9
    6     New Orleans Pelicans  118.3  113.7   4.6
    7     Los Angeles Clippers  119.8  116.5   3.2
    8             Phoenix Suns  118.8  115.7   3.1
    9       Philadelphia 76ers  117.8  114.8   3.0
    10          Indiana Pacers  121.8  118.8   3.0
    11   Golden State Warriors  118.7  116.0   2.7
    12         Milwaukee Bucks  119.1  116.5   2.6
    13     Cleveland Cavaliers  116.2  113.8   2.4
    14           Orlando Magic  114.4  112.3   2.1
    15        Dallas Mavericks  118.4  116.3   2.1
    16        Sacramento Kings  117.7  116.0   1.7
    17              Miami Heat  115.0  113.3   1.6
    18         Houston Rockets  115.3  114.2   1.1
    19      Los Angeles Lakers  116.7  116.2   0.5
    20           Chicago Bulls  115.8  117.5  -1.6
    21           Atlanta Hawks  118.2  120.2  -2.1
    22           Brooklyn Nets  114.1  117.1  -3.0
    23               Utah Jazz  116.4  121.4  -5.0
    24       San Antonio Spurs  110.8  117.2  -6.3
    25         Toronto Raptors  113.1  119.7  -6.5
    26       Memphis Grizzlies  108.1  115.3  -7.2
    27         Detroit Pistons  110.4  119.5  -9.1
    28      Washington Wizards  111.1  120.2  -9.1
    29  Portland Trail Blazers  109.3  118.6  -9.3
    30       Charlotte Hornets  110.3  120.9 -10.6

    Read it top to bottom. The Boston Celtics weren't just the best team - they were in a tier of their own at +11.7, built on a 124.2 offense and a 112.5 defense, both elite. Oklahoma City is a distant second at +7.3. Down at the bottom, Charlotte sits at -10.6, scoring 110.3 and bleeding 120.9 the other way. The whole spread, from +11.7 to -10.6, is what separates a 64-win juggernaut from a lottery team.

    Two teams worth a second look: Indiana posted a sky-high 121.8 offense but a leaky 118.8 defense, netting just +3.0 - elite scoring dragged back to mid-pack by defense. Memphis is the mirror image of bad luck, with the league's weakest offense at 108.1. Net rating makes those trade-offs visible in a way win totals never could.

  5. Draw the diverging "dashboard" chart

    A diverging bar chart is the perfect picture of net rating because the metric has a natural zero. We color bars green above the line (winning teams) and red below it (losing teams), draw a vertical axis at zero, and label each bar with a signed value so the sign is unmistakable.

    python
    import matplotlib.pyplot as plt
    
    plot_df = ratings.sort_values("NRtg")
    colors = [sdt.sport_color("soccer") if v >= 0 else sdt.SPORT_COLORS["baseball"]
              for v in plot_df["NRtg"]]
    fig, ax = plt.subplots(figsize=(8, 8.4))
    ax.barh(plot_df["Team"], plot_df["NRtg"], color=colors)
    ax.axvline(0, color="#20242B", linewidth=0.8)
    ax.bar_label(ax.containers[0], fmt="%+.1f", padding=3, fontsize=8)
    ax.set_title("NBA net rating by team, 2023-24")
    ax.set_xlabel("net rating (points per 100 possessions)")
    ax.tick_params(axis="y", labelsize=8)
    sdt.save_fig(fig, "net_rating_table", source=source)
    Diverging horizontal bar chart of NBA team net ratings for 2023-24, green for positive and red for negative, Boston Celtics highest
    Data: stats.nba.com via nba_api, retrieved June 2026

    The list comprehension that builds colors is the key trick: it walks the net-rating column and picks green or red per team. The "%+.1f" format string forces a leading + or - on every label, and axvline(0, ...) draws the dividing line the whole chart pivots around. The footer credit reads "bundled sample" here because the build server is blocked; on your home machine it will instead credit the live stats.nba.com pull.

Troubleshooting

The live call hangs, then raises ReadTimeout

Expected on a server, and not a bug in your code. stats.nba.com geoblocks data-center and VPN IP addresses, so the request is dropped and eventually fails with requests.exceptions.ReadTimeout. Fixes, in order: (1) run from a home internet connection, not a server, Colab, or VPN; (2) send the full NBA_HEADERS; (3) raise the timeout to 30-60 seconds; (4) add exponential backoff for a second chance; (5) keep to about one request per second to avoid rate-limiting. This is exactly why the table and chart here were built from the bundled real sample.

The ratings look like raw totals, not per-100 numbers

You forgot the measure type. Without measure_type_detailed_defense="Advanced", LeagueDashTeamStats returns basic box-score totals (points, rebounds, assists) and there's no OFF_RATING column at all. Set that argument and the advanced metrics appear.

My NRtg doesn't equal ORtg minus DRtg

It should, to within a tenth. If it's off by more, you're probably mixing columns from two different measure types or comparing a rounded display value to an unrounded one. Recompute it yourself with (ratings["ORtg"] - ratings["DRtg"]).round(1) and compare - they'll line up.

The team labels are cut off on the chart

Long names like "Portland Trail Blazers" need room. We pass bbox_inches="tight" when saving (inside save_fig), which expands the canvas to fit the labels. If they still clip, increase the figure height or shrink the y-tick font with ax.tick_params(axis="y", labelsize=7).

Challenge yourself

A diverging bar shows net rating but hides how a team got there. Build a scatter plot instead: offensive rating on the x-axis, defensive rating on the y-axis (inverted, so good defenses sit at the top), with one dot per team. Draw league-average lines through the middle to split it into four quadrants - good-both, good-offense-only, good-defense-only, and bad-both - and label the Celtics and the Hornets. Then pull last season's table too and chart which teams moved the most. Once you're comfortable with team-level data, try going inside a single team's offense with an NBA shot chart.

Get the code

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

Download the finished script (10_build_an_nba_net_rating_dashboard_table.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, nba_ratings.csv.

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 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