nbadbArena Data Lab
GuidesSet MenuShot Chart Analysis12 waypoints

Playbook

Shot Chart Analysis

Practice drill for turning nbadb shot locations into reliable court maps and zone reads.

Shot Chart Analysis

Use this guide when you want to turn fact_shot_chart into a reliable film-room view of where shots came from and how efficient they were.

Pick the first question

If you need…Start hereFinish with
A fast zone read before plottingZone efficiency queryA table you can sort by attempts or FG%
A first visual sanity checkFirst-pass shot chartA half-court map with makes and misses
Hot and cold area readsEfficiency heatmapA location-based FG% view
Range profile by distanceDistance readA table you can bin or chart later

Filter to one player and one season for your first pass. Get the court rendering right before you widen the sample to teams, lineups, or league-wide heatmaps.

Scope the possession first

Before plotting anything, lock in three choices:

  1. Who are you charting? One player, one team, or the whole league
  2. When are you charting them? A specific season_year and, usually, a season_type
  3. What question are you answering? Volume, efficiency, zone splits, or location clustering

What fact_shot_chart gives you

Each shot record includes four main buckets of context:

BucketUseful columns
Locationloc_x, loc_y
Zonesshot_zone_basic, shot_zone_area, shot_zone_range
Resultshot_made_flag, event_type, shot_type
Game contextperiod, minutes_remaining, seconds_remaining, shot_distance, game_id, player_id

Court coordinates refresher

The NBA shot-coordinate frame used here is the standard half-court orientation:

  • Origin (0, 0) is at the basket
  • loc_x runs left to right
  • loc_y runs baseline toward midcourt
  • A first-pass plot usually focuses on the offensive half court rather than the full floor

Build the analysis in four passes

Pass 1 — Zone efficiency query

Start here when you want the cleanest table before you touch plotting code.

import duckdb

conn = duckdb.connect("nbadb/nba.duckdb")

zones = conn.sql("""
    SELECT
        p.first_name || ' ' || p.last_name AS player,
        sc.shot_zone_basic,
        COUNT(*) AS attempts,
        SUM(sc.shot_made_flag) AS makes,
        ROUND(100.0 * SUM(sc.shot_made_flag) / COUNT(*), 1) AS fg_pct,
        ROUND(
            CASE
                WHEN sc.shot_type = '3PT Field Goal'
                THEN 3.0 * SUM(sc.shot_made_flag) / COUNT(*)
                ELSE 2.0 * SUM(sc.shot_made_flag) / COUNT(*)
            END,
            3
        ) AS pts_per_attempt
    FROM fact_shot_chart sc
    JOIN dim_player p ON sc.player_id = p.player_id
    JOIN dim_game g ON sc.game_id = g.game_id
    WHERE p.last_name = 'Curry'
      AND p.first_name = 'Stephen'
      AND g.season_year = 2024
    GROUP BY 1, 2, sc.shot_type
    ORDER BY attempts DESC
""").pl()

print(zones)

Pass 2 — Build a first-pass shot chart

Start here when you want to confirm the coordinate frame and basic court rendering.

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import polars as pl

shots = conn.sql("""
    SELECT loc_x, loc_y, shot_made_flag
    FROM fact_shot_chart sc
    JOIN dim_player p ON sc.player_id = p.player_id
    JOIN dim_game g ON sc.game_id = g.game_id
    WHERE p.last_name = 'Curry'
      AND p.first_name = 'Stephen'
      AND g.season_year = 2024
""").pl()

fig, ax = plt.subplots(figsize=(12, 11))

made = shots.filter(pl.col("shot_made_flag") == 1)
missed = shots.filter(pl.col("shot_made_flag") == 0)

ax.scatter(missed["loc_x"], missed["loc_y"], c="red", alpha=0.3, s=10, label="Missed")
ax.scatter(made["loc_x"], made["loc_y"], c="green", alpha=0.3, s=10, label="Made")

# Minimal half-court frame
court = patches.Rectangle((-250, -50), 500, 520, fill=False)
hoop = patches.Circle((0, 0), 7.5, fill=False)
ax.add_patch(court)
ax.add_patch(hoop)

ax.set_xlim(-260, 260)
ax.set_ylim(-60, 480)
ax.set_aspect("equal")
ax.legend()
ax.set_title("Stephen Curry Shot Chart 2024-25")
plt.show()

Pass 3 — Convert it into an efficiency heatmap

Start here once the court frame looks right and you want a denser efficiency read.

fig, ax = plt.subplots(figsize=(12, 11))

hb = ax.hexbin(
    shots["loc_x"],
    shots["loc_y"],
    C=shots["shot_made_flag"],
    reduce_C_function=lambda x: sum(x) / len(x),
    gridsize=25,
    cmap="RdYlGn",
    mincnt=3,
)

plt.colorbar(hb, label="FG%")
ax.set_xlim(-260, 260)
ax.set_ylim(-60, 480)
ax.set_aspect("equal")
ax.set_title("Shot Efficiency Heatmap")
plt.show()

Pass 4 — Add a distance read

Start here when the real question is range profile rather than just court position.

distance = conn.sql("""
    SELECT
        shot_distance,
        COUNT(*) AS attempts,
        SUM(shot_made_flag) AS makes,
        ROUND(100.0 * SUM(shot_made_flag) / COUNT(*), 1) AS fg_pct
    FROM fact_shot_chart sc
    JOIN dim_game g ON sc.game_id = g.game_id
    WHERE g.season_year = 2024
      AND g.season_type = 'Regular Season'
    GROUP BY 1
    ORDER BY 1
""").pl()

Sanity checks before you trust the chart

CheckWhy
Confirm the player, season, and season type filterMost misleading charts are really scope mistakes
Join dim_game when you need season-level filteringShot tables alone do not carry every analysis filter you need
Validate the coordinate frame before tuning colorsA beautiful chart on the wrong floor is still wrong
Say when you mix Regular Season and PlayoffsOtherwise the read is not comparable possession-to-possession

Keep moving

Stay in the same possession

Keep the mental model warm with adjacent pages, section hubs, and search-friendly routes into the same topic cluster.

Section hub

On this page