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 here | Finish with |
|---|---|---|
| A fast zone read before plotting | Zone efficiency query | A table you can sort by attempts or FG% |
| A first visual sanity check | First-pass shot chart | A half-court map with makes and misses |
| Hot and cold area reads | Efficiency heatmap | A location-based FG% view |
| Range profile by distance | Distance read | A 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:
- Who are you charting? One player, one team, or the whole league
- When are you charting them? A specific
season_yearand, usually, aseason_type - 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:
| Bucket | Useful columns |
|---|---|
| Location | loc_x, loc_y |
| Zones | shot_zone_basic, shot_zone_area, shot_zone_range |
| Result | shot_made_flag, event_type, shot_type |
| Game context | period, 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_xruns left to rightloc_yruns 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
| Check | Why |
|---|---|
| Confirm the player, season, and season type filter | Most misleading charts are really scope mistakes |
Join dim_game when you need season-level filtering | Shot tables alone do not carry every analysis filter you need |
| Validate the coordinate frame before tuning colors | A beautiful chart on the wrong floor is still wrong |
Say when you mix Regular Season and Playoffs | Otherwise the read is not comparable possession-to-possession |
Related routes
- DuckDB Query Examples for more SQL patterns
- Parquet Usage if you want to run the same drill over Parquet files
- Schema Reference / Facts if you need to compare
fact_shot_chartagainst nearby fact tables
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.