Skip to main content

Shot quality on an NBA court

Where does a team take its shots, and does it make more of them than an average team would from the same spots? This recipe answers both for every field goal attempt of the 2025-26 NBA regular season, with three SportsDataverse packages working together:

  • sportsdataverse-py loads the shots (the stats-API shot file the SportsDataverse publishes on GitHub, so nothing calls stats.nba.com),
  • sdvplot moves them onto sportypy's court and supplies team colors and logos,
  • sportypy draws the court and bins the shots onto it with hexbin.

To run it yourself: pip install sportypy sportsdataverse "sdvplot[surfaces]", or from a clone of the repository, uv run --group cookbook jupyter lab.

Open in Colab · View the notebook on GitHub

import matplotlib.pyplot as plt
import polars as pl
import sportsdataverse.nba as nba
from matplotlib.colors import TwoSlopeNorm

import sdvplot
from sportypy.surfaces.basketball import NBACourt

# print tables as markdown, so the docs site shows them as tables
pl.Config.set_tbl_formatting("ASCII_MARKDOWN")
pl.Config.set_tbl_hide_column_data_types(True)
pl.Config.set_tbl_hide_dataframe_shape(True)
SEASON = 2025 # this loader names a season by the year it starts: 2025 is 2025-26

1. Every shot, on sportypy's frame​

The stats API reports a shot's location in tenths of a foot with the hoop at the origin. sportypy's court is in feet with the origin at center court. sdvplot.court_coords converts one to the other and adds court_x and court_y; every shot lands on the left half of the court. A shot's value is points when it goes in and 0 when it does not.

shots = sdvplot.court_coords(
nba.load_nba_stats_shots(seasons=SEASON).filter(pl.col("season_type_id") == "2") # 2: regular season
).with_columns(
made=pl.col("shot_result") == "Made",
points=pl.when(pl.col("shot_result") == "Made").then(pl.col("shot_value")).otherwise(0),
)
print(f"{shots.height:,} shots by {shots['team_tricode'].n_unique()} teams")
print(shots.select("team_tricode", "player_name", "shot_value", "shot_distance", "court_x", "court_y", "points").head(3))
219,159 shots by 30 teams
team_tricodeplayer_nameshot_valueshot_distancecourt_xcourt_ypoints
HOUSengun325-23.85-17.90
OKCHolmgren21-40.450.02
HOUThompson323-41.7523.30

2. Where the league shoots​

NBACourt.hexbin bins the points into hexagons sized in feet (binsize) and clips them to the court. display_range="defense" draws only the left half, where every shot is. The log color scale keeps the rim, which has many times more attempts than anywhere else, from washing out the rest: the rim, the corners and the arc are the darkest, and the long two in between takes about a tenth as many shots as the arc.

court = NBACourt()
fig, ax = plt.subplots(figsize=(7, 6.5))
court.draw(ax=ax, display_range="defense")
hexes = court.hexbin(shots["court_x"], shots["court_y"], binsize=1.5, bins="log", cmap="magma_r", mincnt=1, ax=ax)
fig.colorbar(hexes, ax=ax, shrink=0.6, pad=0.02, label="Attempts (log scale)")
ax.set_title(f"All {shots.height:,} NBA field goal attempts, 2025-26 regular season", loc="left", fontweight="bold")
fig.text(0.99, 0.01, "Data: NBA stats API shot file via sportsdataverse-py", ha="right", fontsize=8, color="grey")
plt.show()

NBA shot quality: Where the league shoots

3. What a shot is worth from each distance​

The league's average points per attempt at each distance (in whole feet, with everything past 35 feet pooled) and shot value is the baseline for the rest of the recipe: an average team's expected return on the same shot. A two at the rim is worth well over a point; the long two is the worst shot on the floor, and a step back behind the arc is worth more than a step in.

expected = (
shots.with_columns(dist=pl.col("shot_distance").clip(upper_bound=35))
.group_by("dist", "shot_value")
.agg(attempts=pl.len(), xpts=pl.col("points").mean())
.filter(pl.col("attempts") >= 50)
.sort("dist")
)

fig, ax = plt.subplots(figsize=(8, 4))
for value, color in ((2, "#006838"), (3, "#e06000")):
line = expected.filter(pl.col("shot_value") == value)
ax.plot(line["dist"], line["xpts"], marker="o", ms=3, color=color, label=f"{value}-point attempts")
ax.axhline(shots["points"].mean(), color="grey", lw=0.8, ls="--", label="League average, all shots")
ax.set(xlabel="Shot distance (ft)", ylabel="Points per attempt", ylim=(0, None))
ax.spines[["top", "right"]].set_visible(False)
ax.legend(frameon=False)
ax.set_title("Points per attempt by distance, 2025-26", loc="left", fontweight="bold")
plt.show()

NBA shot quality: What a shot is worth from each distance

4. Points over expected, shot by shot​

Join the baseline back onto every shot and each one gets an expected value, xpts. A make from a spot where the league scores 1.1 points per attempt is worth 2 - 1.1 = +0.9 over expected, a miss there -1.1. Summed over a season, that separates teams that take good shots (a high xpts per attempt) from teams that make the shots they take (a high over per attempt).

shots = shots.with_columns(dist=pl.col("shot_distance").clip(upper_bound=35)).join(
expected.select("dist", "shot_value", "xpts"), on=["dist", "shot_value"], how="left"
).with_columns(over=pl.col("points") - pl.col("xpts"))

teams = (
shots.drop_nulls("xpts")
.group_by("team_tricode")
.agg(attempts=pl.len(), pts=pl.col("points").mean(), xpts=pl.col("xpts").mean(), over=pl.col("over").mean())
.sort("over", descending=True)
)
with pl.Config(float_precision=3, tbl_rows=10):
print(pl.concat([teams.head(5), teams.tail(5)]))
team_tricodeattemptsptsxptsover
LAL68631.1461.0690.077
DEN71961.1541.0790.075
BOS73981.1061.0520.053
LAC68571.1181.0720.045
MIL70121.1311.0930.037
PHI74041.0611.098-0.037
CHI74171.0951.146-0.051
POR73891.0671.119-0.051
BKN69331.0411.106-0.065
NOP74201.0541.122-0.068

5. Where the best and worst shooting teams beat the league​

Pass over as the hexbin's values and each hexagon shows the team's average points over expected there (mincnt leaves out spots with too few attempts to say). A diverging palette centered on zero puts the league average in white. The courts are painted in each team's colors from sdvplot (team_colors), handed to sportypy as color_updates, and add_logos puts the team's logo beyond the arc.

best, worst = teams["team_tricode"][0], teams["team_tricode"][-1]
norm = TwoSlopeNorm(vcenter=0, vmin=-0.6, vmax=0.6)

fig, axes = plt.subplots(1, 2, figsize=(12, 5.4))
for ax, team in zip(axes, (best, worst)):
primary = sdvplot.team_colors("nba", team)
secondary = sdvplot.team_colors("nba", team, which="secondary")
court = NBACourt(color_updates={"court_apron": primary, "center_circle_fill": secondary})
court.draw(ax=ax, display_range="defense")
mine = shots.filter((pl.col("team_tricode") == team) & pl.col("over").is_not_null())
hexes = court.hexbin(mine["court_x"], mine["court_y"], values=mine["over"], binsize=4, mincnt=20,
cmap="RdBu_r", norm=norm, edgecolors="white", linewidths=0.3, ax=ax)
sdvplot.add_logos(ax, [-12], [0], [team], league="nba", height=0.12, zorder=30)
row = teams.filter(pl.col("team_tricode") == team).row(0, named=True)
ax.set_title(f"{team}: {row['over']:+.3f} points per shot over expected", loc="left", fontsize=10)
fig.colorbar(hexes, ax=axes, shrink=0.6, pad=0.02, label="Points over expected per attempt")
fig.suptitle("The best and worst shooting teams of 2025-26, spot by spot", x=0.01, ha="left", fontweight="bold")
fig.text(0.99, 0.01, "Data: NBA stats API shot file via sportsdataverse-py", ha="right", fontsize=8, color="grey")
plt.show()

NBA shot quality: Where the best and worst shooting teams beat the league

Where to go next​

  • The NBACourt reference lists every feature color_updates can repaint, and the plotting reference every option of hexbin, heatmap and contour.
  • sdvplot's surfaces and shot charts cookbook draws single-player shot charts on team-colored courts in one call.
  • Swap nba for wnba (load_wnba_stats_shots, sdvplot.court_coords and sportypy's WNBACourt) and the recipe runs unchanged on the WNBA.