Make a Shot-Density Heatmap with hexbin
Part 8 of 10 in Sports Data Visualization · course bundle (code + data)
What you'll build
Two hexbin maps of 25,000 real NBA shots - a density map of where shots come from and an efficiency map of the make rate by location.

Try to plot 25,000 shots as a scatter and you get a black blob — every interesting pattern buried under overlapping dots. The fix is hexbin: tile the court into hexagons and color each one by what's inside it. It's a histogram in two dimensions, and it's the foundation of every shot chart, heat map, and spatial-density plot you've ever seen. By the end you'll have two maps — where shots come from, and where they actually go in.
This builds on Your First Sports Data Visualization and Binning Data with pd.cut (hexbin is binning in 2D). The data is the bundled nba_league_shots.csv (25,000 real NBA shot locations), so it runs offline.
-
Load the shots and look at the coordinates
Each row is one shot with an
LOC_X(left-right, in feet from center) andLOC_Y(distance up the court). We keep the front-court half, where essentially all shots happen.python import pandas as pd shots = pd.read_csv("nba_league_shots.csv") shots = shots[(shots["LOC_Y"] >= 0) & (shots["LOC_Y"] <= 40)].copy() print("Shots loaded:", len(shots)) print("X (left-right) range: %.0f to %.0f feet" % (shots["LOC_X"].min(), shots["LOC_X"].max())) print("Y (up the court) range: %.0f to %.0f feet" % (shots["LOC_Y"].min(), shots["LOC_Y"].max())) print("Overall make rate: %.1f%%" % (100 * shots["SHOT_MADE"].mean()))What's in the fileShots loaded: 24930 X (left-right) range: -25 to 25 feet Y (up the court) range: 1 to 40 feet Overall make rate: 47.3%
Coordinates plus a made/missed flag are all hexbin needs. The baseline is
LOC_Y = 0and the hoop sits a little over five feet up the floor, at(0, 5.25); positiveLOC_Ymoves out toward half-court. -
The density map: where shots come from
One call does it.
hexbintakes the x and y arrays, splits the plane into a grid of hexagons (gridsizecontrols how many), and colors each by the count of points inside.mincnt=1hides empty hexes so the court background stays clean.python import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(7.5, 7)) hb = ax.hexbin(shots["LOC_X"], shots["LOC_Y"], gridsize=30, cmap="inferno", mincnt=1) fig.colorbar(hb, ax=ax, label="shots in hex") ax.set_aspect("equal") # don't distort the court! fig.savefig("shot_density.png", dpi=144, bbox_inches="tight")
Data: Bundled 25,000-shot sample of the public NBA_Shots_04_25 shot log, retrieved June 2026 The shape of the modern NBA appears instantly: a blazing cluster right at the rim, a bright ring along the three-point arc, and a sparse, dim mid-range between them. That
set_aspect("equal")line matters — without it the court stretches and the hexagons turn into ovals, distorting every distance. -
The efficiency map: the real payoff
Density shows where teams shoot; the better question is where they score. hexbin can color each hex by any statistic, not just a count: pass
C=a value per point andreduce_C_function=how to aggregate it. Feed it the made/missed flag withnp.meanand each hex shows its make rate. We require at least 20 shots per hex (mincnt=20) so a lucky 1-for-1 hex doesn't paint a fake 100%.python import numpy as np fig, ax = plt.subplots(figsize=(7.5, 7)) hb = ax.hexbin(shots["LOC_X"], shots["LOC_Y"], C=shots["SHOT_MADE"].astype(float), # the value to aggregate reduce_C_function=np.mean, # mean of made(1)/missed(0) = make rate gridsize=30, cmap="RdYlGn", mincnt=20) fig.colorbar(hb, ax=ax, label="make rate") ax.set_aspect("equal") fig.savefig("shot_efficiency.png", dpi=144, bbox_inches="tight")
Data: Bundled 25,000-shot sample of the public NBA_Shots_04_25 shot log, retrieved June 2026 That
C/reduce_C_functionpair is the trick worth remembering: swap in any column and any function and the same map shows average shot distance, average shot value, anything per location. It turns a density plot into an analytics tool. -
Read the map in numbers
A chart should always be backed by the figures. Group by the zone label and check the make rates the colors are showing.
python z = (shots.groupby("BASIC_ZONE") .agg(shots=("SHOT_MADE", "size"), make_rate=("SHOT_MADE", "mean")) .sort_values("shots", ascending=False)) z["make_rate"] = (100 * z["make_rate"]).round(1) print("Shots and make rate by zone:") print(z.to_string()) print("\nThe restricted area is bright green (high make rate); the long") print("mid-range is the cold yellow band the modern NBA has abandoned.")Make rate by zoneShots and make rate by zone: shots make_rate BASIC_ZONE Restricted Area 7379 65.8 Above the Break 3 7238 36.1 In The Paint (Non-RA) 4971 44.2 Mid-Range 2779 40.9 Left Corner 3 1310 38.9 Right Corner 3 1253 38.2 The restricted area is bright green (high make rate); the long mid-range is the cold yellow band the modern NBA has abandoned.The restricted area converts around 66% and the long mid-range around 41% — the exact gap that pushed the league toward layups and threes. The map didn't just look nice; it pointed straight at the most important strategic shift in modern basketball.
Put a court under it
This section was first published on 17 June 2026 as a separate tutorial, Draw a League-Wide NBA Shot Heatmap. Its chart comes from that tutorial's own script, run on the same bundled nba_league_shots.csv.
A density map reads faster when the reader can see the floor under it. matplotlib builds a court out of patches (circles, arcs and rectangles added to an axis), measured in feet with the baseline at y = 0 and the rim at (0, 5.25). Wrap them in one function, draw the court first, then lay the hexbin on top with its grid pinned to the court rectangle. This block keeps every half-court shot (LOC_Y < 47), so the binning region runs all the way to half-court.
import matplotlib.patches as mp
import matplotlib.pyplot as plt
import pandas as pd
court_shots = pd.read_csv("nba_league_shots.csv")
court_shots = court_shots[court_shots["LOC_Y"] < 47] # drop rare backcourt heaves
def draw_court(ax):
"""A regulation NBA half-court in feet: baseline at y=0, hoop at (0, 5.25)."""
line = "#20242B"
ax.add_patch(mp.Circle((0, 5.25), 0.75, fill=False, color=line, lw=1.5)) # rim
ax.plot([-3, 3], [4, 4], color=line, lw=2) # backboard
ax.add_patch(mp.Rectangle((-8, 0), 16, 19, fill=False, color=line, lw=1.5)) # paint
ax.add_patch(mp.Circle((0, 19), 6, fill=False, color=line, lw=1.5)) # FT circle
ax.add_patch(mp.Arc((0, 5.25), 8, 8, theta1=0, theta2=180, color=line, lw=1.5)) # restricted
ax.plot([-22, -22], [0, 14.2], color=line, lw=1.5) # left corner 3
ax.plot([22, 22], [0, 14.2], color=line, lw=1.5) # right corner 3
ax.add_patch(mp.Arc((0, 5.25), 47.5, 47.5, theta1=22.1, theta2=157.9, color=line, lw=1.5)) # arc
ax.add_patch(mp.Rectangle((-25, 0), 50, 47, fill=False, color=line, lw=1.5)) # boundary
fig, ax = plt.subplots(figsize=(7.4, 7))
draw_court(ax)
hb = ax.hexbin(court_shots["LOC_X"], court_shots["LOC_Y"], gridsize=40,
extent=(-25, 25, 0, 47), cmap="inferno", mincnt=1, alpha=0.85)
ax.set_xlim(-25.5, 25.5)
ax.set_ylim(-1, 47.5)
ax.set_aspect("equal")
ax.axis("off")
fig.colorbar(hb, ax=ax, shrink=0.6).set_label("shots", fontsize=9)
ax.set_title("Where the NBA shoots from (sampled season)")
The arcs are the lines worth a second look. The three-point arc is an Arc centred on the rim with a width and height of 47.5 feet (a diameter, so a 23.75-foot radius), drawn only between the angles where it leaves the straight corner segments at 22 feet. extent=(-25, 25, 0, 47) pins the hexagons to the court rectangle so they line up with the floor, and the slight alpha=0.85 lets the court lines show faintly through the colour. On the court, the rim cell is the single brightest spot, the three-point line traces a second band that is hottest in the corners, and the mid-range between them is visibly hollow.
Troubleshooting
My court looks stretched / squished
You're missing ax.set_aspect("equal"). Without it matplotlib scales the x and y axes independently to fill the figure, so a round rim looks like an oval and distances lie. Always set equal aspect for spatial data.
The efficiency map has a few wild 0% or 100% hexes
Those are hexes with almost no shots. Raise mincnt (we used 20) so only hexes with a real sample get colored. It's the 2D version of not trusting a batting average over three at-bats.
How do I pick gridsize?
It's the resolution dial: larger gridsize = more, smaller hexes = finer detail but noisier and more empty cells. Smaller = smoother but blurrier. Try 20, 30, and 50 on the same data and pick the one that shows structure without looking grainy.
Challenge yourself
Make an efficiency map for a single star (filter to one PLAYER_NAME from nba_player_shots.csv) and compare it to the league map — where is this player hot or cold relative to average? Then swap reduce_C_function to show average SHOT_DISTANCE per hex, or color by points-per-shot (a three is worth 1.5× a two) to turn the make-rate map into a shot-value map, the metric teams actually optimize.
The finished script
Everything this tutorial built, assembled in one runnable file.
Download the finished script (66_shot_density_heatmap_hexbin.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_league_shots.csv. Or skip the collecting: the Sports Data Visualization bundle has this whole course’s scripts and data in one ZIP.


