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
Live shot chart preview
The interactive chart below renders a small sample of shot locations on a half-court diagram. Green dots are makes, red dots are misses. This is the same ShotChart component you can use in any MDX page once you have real query results.
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.
Analytics Quickstart
Land quick wins fast and move from setup to analysis with intent.
SQL Playground
Rehearse DuckDB syntax and query structure in the browser before touching the full warehouse.
DuckDB Query Examples
Move from the browser sandbox into real warehouse query patterns and analyst-ready examples.
