Make a Shot-Density Heatmap with hexbin

BasketballIntermediatePython~5 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 sample (25,000 real NBA shot locations), 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)]
    print(len(shots), "shots")
    print(shots[["LOC_X", "LOC_Y", "SHOT_MADE"]].head())
    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 hoop sits near (0, 0); 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 sample (25,000 real NBA shot locations), 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 sample (25,000 real NBA shot locations), 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))
    print(z)
    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.

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.

Get the code

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

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.

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