SportsDataTutorials

Make a Shot-Density Heatmap with hexbin

Part 8 of 10 in Sports Data Visualization · course bundle (code + data)

BasketballIntermediatePython~8 min read

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.

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.
Data: Bundled 25,000-shot sample of the public NBA_Shots_04_25 shot log, retrieved June 2026

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.

  1. Load the shots and look at the coordinates

    Each row is one shot with an LOC_X (left-right, in feet from center) and LOC_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 file
    Shots 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 = 0 and the hoop sits a little over five feet up the floor, at (0, 5.25); positive LOC_Y moves out toward half-court.

  2. The density map: where shots come from

    One call does it. hexbin takes the x and y arrays, splits the plane into a grid of hexagons (gridsize controls how many), and colors each by the count of points inside. mincnt=1 hides 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")
    A hexbin density map of 25,000 NBA shots, with bright hexes clustered at the rim and along the three-point arc, and a sparse mid-range
    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.

  3. 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 and reduce_C_function= how to aggregate it. Feed it the made/missed flag with np.mean and 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")
    A hexbin efficiency map colored by make rate, bright green at the rim, fading to yellow and orange through the mid-range and out to the three-point line
    Data: Bundled 25,000-shot sample of the public NBA_Shots_04_25 shot log, retrieved June 2026

    That C / reduce_C_function pair 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.

  4. 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 zone
    Shots 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.

python
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)")
Hexbin density heatmap of nearly 25,000 NBA shots on a half-court, glowing brightest at the rim and along the three-point line with a dim mid-range
Data: Bundled sample from the public NBA_Shots_04_25 shot log, retrieved June 2026

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.

Built from public data and the sources named in this tutorial; the printed outputs and charts come from running the tutorial's own script. How this site is made · about the site

Progress is saved only in this browser.