Playbook
Player Comparison
Hands-on playbook for comparing player profiles across box score, shooting, and clutch possessions.
Player Comparison
Use nbadb to build comprehensive player comparisons across any statistical dimension. Start with a tight rotation of players, then swap names, seasons, or filters to run the same set again.
Pick the comparison lane
| If you need… | Start here |
|---|---|
| One baseline table with counting stats | Box score baseline |
| One player across multiple seasons | Trend line |
| Efficiency and shooting volume | Shooting and efficiency |
| Closing-time shot making | Closing-time read |
| A presentation-friendly visual | Turn the comparison into a radar chart |
Coaching note: Hold season and eligibility filters constant across every query so the comparison stays possession-for-possession instead of mixing different contexts.
Set up the rotation
import duckdb
import polars as pl
conn = duckdb.connect("nba.duckdb")
players = ["LeBron James", "Kevin Durant", "Stephen Curry"]
placeholders = ", ".join(f"'{p}'" for p in players)Swap the names once, then reuse placeholders in each comparison query below.
Box score baseline
Start here when you need the quickest side-by-side production read.
comparison = conn.sql(f"""
SELECT
p.first_name || ' ' || p.last_name AS player,
COUNT(*) AS games,
ROUND(AVG(b.pts), 1) AS ppg,
ROUND(AVG(b.reb), 1) AS rpg,
ROUND(AVG(b.ast), 1) AS apg,
ROUND(AVG(b.stl), 1) AS spg,
ROUND(AVG(b.blk), 1) AS bpg,
ROUND(AVG(b.tov), 1) AS topg,
ROUND(AVG(b.plus_minus), 1) AS plus_minus
FROM fact_box_score_player b
JOIN dim_player p ON b.player_id = p.player_id
JOIN dim_game g ON b.game_id = g.game_id
WHERE (p.first_name || ' ' || p.last_name) IN ({placeholders})
AND g.season_type = 'Regular Season'
AND g.season_year = 2024
GROUP BY 1
ORDER BY ppg DESC
""").pl()
print(comparison)Trend line
Use this when the question is “how has this player changed?” rather than “who is better right now?”
trends = conn.sql("""
SELECT
p.first_name || ' ' || p.last_name AS player,
g.season_year,
COUNT(*) AS games,
ROUND(AVG(b.pts), 1) AS ppg,
ROUND(AVG(b.reb), 1) AS rpg,
ROUND(AVG(b.ast), 1) AS apg
FROM fact_box_score_player b
JOIN dim_player p ON b.player_id = p.player_id
JOIN dim_game g ON b.game_id = g.game_id
WHERE p.last_name = 'James' AND p.first_name = 'LeBron'
AND g.season_type = 'Regular Season'
GROUP BY 1, 2
ORDER BY g.season_year
""").pl()
print(trends)Shooting and efficiency
Use this when raw box score volume is not enough and you need shot quality context too.
shooting = conn.sql(f"""
SELECT
p.first_name || ' ' || p.last_name AS player,
COUNT(*) AS games,
ROUND(AVG(b.fg_pct) * 100, 1) AS fg_pct,
ROUND(AVG(b.fg3_pct) * 100, 1) AS fg3_pct,
ROUND(AVG(b.ft_pct) * 100, 1) AS ft_pct,
ROUND(SUM(b.pts) / NULLIF(2.0 * (SUM(b.fga) + 0.44 * SUM(b.fta)), 0) * 100, 1) AS ts_pct,
ROUND(AVG(b.fg3a), 1) AS fg3a_pg
FROM fact_box_score_player b
JOIN dim_player p ON b.player_id = p.player_id
JOIN dim_game g ON b.game_id = g.game_id
WHERE (p.first_name || ' ' || p.last_name) IN ({placeholders})
AND g.season_type = 'Regular Season'
AND g.season_year = 2024
GROUP BY 1
""").pl()
print(shooting)Closing-time read
Use this when you want a tighter late-game shot-making comparison.
clutch = conn.sql(f"""
SELECT
p.first_name || ' ' || p.last_name AS player,
COUNT(*) AS clutch_shots,
SUM(CASE WHEN pbp.shot_result = 'Made' THEN 1 ELSE 0 END) AS makes,
ROUND(100.0 * SUM(CASE WHEN pbp.shot_result = 'Made' THEN 1 ELSE 0 END) / COUNT(*), 1) AS fg_pct
FROM fact_play_by_play pbp
JOIN dim_player p ON pbp.person_id = p.player_id
JOIN dim_game g ON pbp.game_id = g.game_id
WHERE (p.first_name || ' ' || p.last_name) IN ({placeholders})
AND pbp.period >= 4
AND pbp.is_field_goal = 1
AND g.season_type = 'Regular Season'
GROUP BY 1
ORDER BY makes DESC
""").pl()
print(clutch)Turn the comparison into a radar chart
Join the baseline and shooting tables first so the chart includes both counting stats and efficiency.
import matplotlib.pyplot as plt
import numpy as np
categories = ["PPG", "RPG", "APG", "SPG", "BPG", "TS%"]
metrics = ["ppg", "rpg", "apg", "spg", "bpg", "ts_pct"]
radar_source = comparison.join(
shooting.select(["player", "ts_pct"]),
on="player",
)
# Normalize each stat to a 0-1 scale relative to this player pool.
def normalize(val, min_val, max_val):
return (val - min_val) / (max_val - min_val) if max_val > min_val else 0
mins = {metric: radar_source[metric].min() for metric in metrics}
maxs = {metric: radar_source[metric].max() for metric in metrics}
angles = np.linspace(0, 2 * np.pi, len(categories), endpoint=False)
angles = np.concatenate([angles, [angles[0]]])
fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))
for row in radar_source.iter_rows(named=True):
values = [normalize(row[metric], mins[metric], maxs[metric]) for metric in metrics]
values = np.concatenate([values, [values[0]]])
ax.plot(angles, values, label=row["player"])
ax.fill(angles, values, alpha=0.1)
ax.set_xticks(angles[:-1])
ax.set_xticklabels(categories)
ax.legend(loc="upper right", bbox_to_anchor=(1.3, 1.0))
plt.title("Player Comparison Radar")
plt.show()Important: this chart is relative to the selected player pool. If you change the pool, the normalized shape changes too.
Related routes
- Analytics Quickstart for the shortest route into your first query
- DuckDB Query Examples for more reusable SQL patterns
- Shot Chart Analysis when the comparison shifts from box score to location data
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.
Shot Chart Analysis
Lean into basketball-native visual storytelling on one of the best-fit pages.
Visual Asset Prompt Pack
Generate hero art, OG cards, icons, and texture systems without losing the docs identity.