Draw an NBA Shot Chart with matplotlib

BasketballIntermediatePython~10 min read

What you'll build

A half-court drawn in matplotlib with a player's makes and misses plotted on it.

A half-court drawn in matplotlib with a player's makes and misses plotted on it.
Data: stats.nba.com via nba_api, retrieved June 2026

A shot chart is one of the most recognizable images in basketball analytics: a half-court dotted with makes and misses, telling you in one glance where a player lives on the floor. You'll build one from scratch - a regulation NBA half-court drawn in matplotlib line by line, then Luka Doncic's makes and misses plotted on top. Here's the part most walkthroughs gloss over: the whole thing only works if two coordinate systems agree, and getting them to agree is where I spend the most care.

This builds on your first NBA pull for the data side, and it's a cousin of the NHL shot-location plot - same idea, a very different rink. Here the challenge isn't the data, it's the geometry.

The usual NBA caveat applies: the live nba_api shot pull works from home but times out from data-center and VPN IPs, so this page was built from a bundled real sample drawn from a public NBA shot log. Luka's shots and counts are genuine; on your machine the same code pulls them live.

  1. The court coordinate system

    Before any drawing, decide on units. We'll work in feet, with the origin at the center of the baseline directly under the hoop. So x runs left-to-right across the court (negative on the left, positive on the right), and y runs from the baseline (y = 0) up toward half-court. The rim sits at (0, 5.25) - dead center, 5.25 feet out from the baseline, which is where the center of an NBA hoop actually is.

    Real dimensions anchor everything else: the court is 50 feet wide (so x spans -25 to 25), the paint is 16 feet wide and 19 feet deep, the three-point arc is 23.75 feet from the rim at the top and 22 feet in the corners, and half-court is 47 feet from the baseline. Keep those numbers handy - every line we draw is one of them.

  2. Draw the court furniture

    matplotlib draws shapes with patches - circles, arcs, rectangles - that you add to an axis. We wrap the whole court in one function so we can reuse it. Each line below is one real court marking; read the comments as a tour of the floor.

    python
    import matplotlib.patches as mp
    
    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
        # three-point line: straight corners then the arc
        ax.plot([-22, -22], [0, 14.2], color=line, lw=1.5)
        ax.plot([22, 22], [0, 14.2], color=line, lw=1.5)
        ax.add_patch(mp.Arc((0, 5.25), 47.5, 47.5, theta1=22.1, theta2=157.9, color=line, lw=1.5))
        ax.add_patch(mp.Arc((0, 47), 12, 12, theta1=180, theta2=360, color=line, lw=1.5))  # center
        ax.plot([-25, 25], [47, 47], color=line, lw=1.5)                               # half-court
        ax.add_patch(mp.Rectangle((-25, 0), 50, 47, fill=False, color=line, lw=1.5))   # boundary

    A few of these reward a closer look. The rim is a small Circle of radius 0.75 ft centered at (0, 5.25). The paint is a Rectangle whose bottom-left corner is at (-8, 0), 16 feet wide and 19 deep. The restricted-area arc and the three-point arc are both Arc patches centered on the rim; an Arc takes a full width and height (so 47.5 is the diameter, giving the 23.75-ft radius) plus a start and end angle. The corner threes are straight vertical lines that meet the arc at about y = 14.2 feet, which is why we stop them there.

  3. Pull the shots and convert the coordinates

    Here's the subtle part. The ShotChartDetail endpoint returns each shot's location, but in a different system than ours: nba_api measures in tenths of a foot, with the origin at the hoop. To plot those on our court we must do two conversions - divide by 10 to get feet, and shift the y-axis so the origin moves from the hoop down to the baseline by adding the hoop's 5.25-ft offset.

    python
    from nba_api.stats.endpoints import shotchartdetail
    
    PLAYER_ID, TEAM_ID = 1629029, 1610612742  # Luka Doncic, Dallas
    
    s = shotchartdetail.ShotChartDetail(
        team_id=TEAM_ID, player_id=PLAYER_ID, season_nullable="2023-24",
        season_type_all_star="Regular Season", context_measure_simple="FGA",
        headers=NBA_HEADERS, timeout=30)
    df = s.get_data_frames()[0]
    
    # nba_api: tenths of a foot, origin at the hoop -> feet, origin at the baseline.
    shots = pd.DataFrame({"x": df["LOC_X"] / 10,
                          "y": df["LOC_Y"] / 10 + 5.25,
                          "made": df["SHOT_MADE_FLAG"] == 1})

    Read the two transformed columns carefully. LOC_X / 10 turns tenths-of-a-foot into feet; left-right stays the same because both systems put x = 0 at the center. LOC_Y / 10 + 5.25 does the same unit conversion and slides the origin: a shot taken at the rim has LOC_Y near 0 in the API, which becomes y = 5.25 on our court - exactly where the rim sits. The context_measure_simple="FGA" argument asks for all field-goal attempts (makes and misses), and SHOT_MADE_FLAG == 1 turns the make/miss code into a clean boolean.

  4. Use the live-or-bundled fallback

    Same pattern as the other NBA tutorials. The bundled sample is already stored in our feet-from-the-baseline system, so its loader is a straight passthrough - no conversion needed - while the live path does the tenths-to-feet math above.

    python
    def bundled_loader():
        d = sdt_nba.bundled("nba_player_shots.csv")
        return pd.DataFrame({"x": d["LOC_X"], "y": d["LOC_Y"],
                             "made": d["SHOT_MADE"].astype(bool)})
    
    shots, source = sdt_nba.live_or_bundled(
        live_call, bundled_loader, f"{PLAYER}'s shots")

    That the bundled data is pre-converted is deliberate: it means the plotting code that follows is identical whether the shots came live or from the sample. Only the source credit on the chart differs.

  5. Count the makes and misses

    Before plotting, it's worth printing a one-line summary so you know the data loaded sanely and to sanity-check the conversion. We sum the boolean made column (True counts as 1), divide by the total, and peek at the first few rows.

    python
    made = int(shots["made"].sum())
    print(f"{PLAYER}: {made} makes on {len(shots)} attempts "
          f"({made / len(shots):.1%}).")
    sdt.show_df(shots, n=6)
    Luka's shot totals
    Luka Doncic: 804 makes on 1652 attempts (48.7%).
          x      y   made
    0  -1.6   7.85   True
    1  16.9  24.05  False
    2   8.6  30.35   True
    3  -3.2   6.35  False
    4   3.8   7.25  False
    5   6.7  13.25   True

    Luka took 1,652 field-goal attempts and made 804 of them - a 48.7% clip across the season. Glance at the sample rows too: every y is positive and most cluster between 6 and 30, exactly the range you'd expect for shots measured from the baseline. If your y values came out negative or centered on zero, you'd know the 5.25 shift went missing.

  6. Plot the shots on the court

    Now we put it together: draw the court, split the shots into makes and misses, and scatter each group with its own marker. Misses get a faint x, makes get a filled o in the basketball accent color. Setting an equal aspect ratio is essential - without it, the court would stretch and your nice circular arcs would turn into ellipses.

    python
    import matplotlib.pyplot as plt
    
    fig, ax = plt.subplots(figsize=(7.2, 6.8))
    draw_court(ax)
    makes = shots[shots["made"]]
    misses = shots[~shots["made"]]
    ax.scatter(misses["x"], misses["y"], marker="x", s=26, linewidths=1,
               color="#B0837A", alpha=0.7, label="missed")
    ax.scatter(makes["x"], makes["y"], marker="o", s=26,
               color=sdt.sport_color("basketball"), alpha=0.8,
               edgecolor="#FBF7EE", linewidth=0.3, label="made")
    ax.set_xlim(-25.5, 25.5)
    ax.set_ylim(-1, 47.5)
    ax.set_aspect("equal")
    ax.axis("off")
    ax.legend(loc="upper right", fontsize=9, frameon=False)
    ax.set_title(f"{PLAYER} shot chart, 2023-24")
    sdt.save_fig(fig, "shot_chart", source=source)
    NBA half-court shot chart for Luka Doncic 2023-24, with made shots as filled dots and misses as faint x marks
    Data: stats.nba.com via nba_api, retrieved June 2026

    The shape of Luka's season jumps out: a dense cluster at the rim, a thick band of three-point attempts beyond the arc, and a noticeable scatter of step-back jumpers just inside it - his signature shot. The alpha transparency lets you read density where shots pile up, and turning the axes off with ax.axis("off") leaves just the court and the dots. The footer credits the bundled sample here because the build server is blocked; at home it will credit your live stats.nba.com pull.

Troubleshooting

The live call hangs, then raises ReadTimeout

Expected from a server, and not a bug in your code. stats.nba.com geoblocks data-center and VPN IP addresses, so the shot pull 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; (5) keep to about one request per second. That's why this page's chart was built from the bundled real sample - the build server can't reach the live endpoint, but your machine can.

The shots land in the wrong place on the court

Almost always a coordinate mismatch. Remember the live endpoint returns tenths of a foot from the hoop: you must divide LOC_X and LOC_Y by 10 and add 5.25 to y to move the origin to the baseline. If shots cluster near y = 0 or go negative, the 5.25 shift is missing; if the court looks tiny next to the dots, you forgot to divide by 10.

The arcs look like ellipses, not circles

You're missing ax.set_aspect("equal"). Without it, matplotlib scales the x and y axes independently to fill the figure, squashing the court. Setting the aspect to equal forces one foot on x to equal one foot on y, so circles stay circular.

Makes hide behind misses (or vice versa)

Whichever you scatter last sits on top. We plot misses first, then makes, so the makes read clearly. Tune alpha and marker size s to balance them, and give makes a thin light edgecolor so they pop against the court lines.

Challenge yourself

Dots tell you where shots happened; they don't tell you where a player is efficient. Replace the scatter with a hexbin (ax.hexbin) colored by field-goal percentage in each cell, so hot and cold zones light up. Then add the league-average mark at each distance and shade where Luka beats it - that's the difference between a volume map and a true efficiency chart. For a different shape of the same idea, see how a rink changes everything in the NHL shot-location plot.

Get the code

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

Download the finished script (11_draw_an_nba_shot_chart_with_matplotlib.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_player_shots.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 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