Lab 01 · NHL play-by-play
Calgary Flames shot analysis
Every Flames shot attempt since 2021-22, including this season. Where they shoot from, and where goals come from.
- Last full season2025-26, 77 points
- 34-39-9
- Top goal scorer22 goals in 2025-26
- Morgan Frost
- Games reconciled to the official scoreevery game, every season
- 413
Ask the Flames data
Claude writes the SQL, your browser runs it. Guardrails
01
Dashboard
Source: NHL public API, schedules and play-by-play. Regular season only.
02
Why it matters
Anyone can generate a dashboard now. The value is knowing what to build, and what it’s for.
In a sales or marketing team
The same techniques, applied to revenue data.
Attribution that ties back to booked revenue, so finance and marketing report one number.
Ad, CRM and ticketing data pulled safely, with rate limits and only the endpoints you need.
Messy app and ad-platform events turned into one consistent view of each customer.
Decisions and trade-offs
What I chose, why, and what it cost.
Flatten JSON in Python, keep business rules in dbt
- Rules like power play and shot direction stay readable and tested in one place.
- Two languages in the pipeline, with a clear line between them.
Proxy exactly two NHL URL shapes
- The NHL API blocks browsers, and an open proxy would be abused.
- A new endpoint needs a code change. That’s the point.
Cache finished games forever
- A finished game rarely changes, so each one is fetched once.
- A late correction by the NHL wouldn’t be picked up without clearing the cache.
03
Pipeline
Every number above comes from this pipeline: dbt models in raw, cleaned and business-ready layers, with tests and a contract. An Airflow DAG runs it weekly. You can run it in your browser too.
- Last production run
- 2026-10-07
- Trigger
- Local run
- Duration
- 0.6 s
- Rows extracted
- 67,078
- Tests
- 9 pass · 0 warn
Lineage
Click a node for its SQL, schema and row counts.
1 Source
2 Bronze
3 Silver
4 Gold
5 Checks
6 Browser
stg_gamesdbt/models/flames/stg_games.sql
413 rows · built in 58 ms · last production run
Finished regular-season games, re-expressed from the Flames' side of the ice.
-- Finished regular-season games, re-expressed from the Flames' side of the ice.
select
game_id,
substr(season, 1, 4) || '-' || substr(season, 7, 2) as season,
cast(game_date as date) as game_date,
home_id = 20 as home,
case when home_id = 20 then away_abbrev else home_abbrev end as opponent,
case when home_id = 20 then home_score else away_score end as goals_for,
case when home_id = 20 then away_score else home_score end as goals_against,
coalesce(last_period_type, 'REG') as decided_in
from {{ ref('raw_games') }}
where game_type = 2
and game_state in ('OFF', 'FINAL')
qualify row_number() over (partition by game_id) = 1| Column | Type | Null % |
|---|---|---|
| game_id | BIGINT | 0 |
| season | VARCHAR | 0 |
| game_date | DATE | 0 |
| home | BOOLEAN | 0 |
| opponent | VARCHAR | 0 |
| goals_for | INTEGER | 0 |
| goals_against | INTEGER | 0 |
| decided_in | VARCHAR | 0 |
Data quality tests
From the last production run. Warnings are real issues in the source, flagged rather than hidden.
✓ passresult is W, L or OTLaccepted_values · flames_games0
No ties, no unknowns. severity: error
select count(*) as failures from ( with all_values as ( select result as value_field, count(*) as n_records from main.flames_games group by result ) select * from all_values where value_field not in ( 'W','L','OTL' ) ) dbt_test✓ passCoordinate flip points shots at the attacking netsingular · flames_shots0
At least 95% of unblocked attempts should land in the attacking half. If the API changes its side convention, this catches it. severity: warn
select count(*) as failures from ( select avg((x > 0)::integer) as share_in_attacking_half from main.flames_shots where event <> 'blocked-shot' having avg((x > 0)::integer) < 0.95 ) dbt_test✓ passPoints match the resultexpression_is_true · flames_games0
Standings logic is applied consistently. severity: error
select count(*) as failures from ( select * from main.flames_games where not (points = case result when 'W' then 2 when 'OTL' then 1 else 0 end) ) dbt_test✓ passCoordinates are on the iceexpression_is_true · flames_shots0
A rink is 200 by 85 feet. severity: error
select count(*) as failures from ( select * from main.flames_shots where not (x between -100 and 100 and y between -43 and 43) ) dbt_test✓ passShot-level goals reconcile to the final scoresingular · flames_shots0
Counts goal events per game and compares them to the official score, allowing for the extra goal a shootout winner is credited. severity: error
select count(*) as failures from ( -- Returns games whose shot-level goals don't match the official score. with shot_goals as ( select game_id, count(*) filter (where is_goal and team = 'CGY') as gf, count(*) filter (where is_goal and team = 'OPP') as ga from main.flames_shots group by 1 ) select g.game_id from main.flames_games g left join shot_goals s using (game_id) where g.goals_for - (g.decided_in = 'SO' and g.result = 'W')::integer <> coalesce(s.gf, 0) or g.goals_against - (g.decided_in = 'SO' and g.result <> 'W')::integer <> coalesce(s.ga, 0) ) dbt_test✓ passShooter name is presentnot_null · flames_shots0
A few events arrive without a matching roster entry. severity: warn
select count(*) as failures from ( select shooter from main.flames_shots where shooter is null ) dbt_test✓ passEvery shot belongs to a known gamerelationships · flames_shots0
Referential integrity between the two served tables. severity: error
select count(*) as failures from ( with child as ( select game_id as from_field from main.flames_shots where game_id is not null ), parent as ( select game_id as to_field from main.flames_games ) select from_field from child left join parent on child.from_field = parent.to_field where parent.to_field is null ) dbt_test✓ passAt least 82 gamesrow_count_at_least · flames_games0
Guards against a partial extract replacing full seasons. severity: error
select count(*) as failures from ( select count(*) as row_count from main.flames_games having count(*) < 82 ) dbt_test✓ passgame_id is uniqueunique · flames_games0
One row per game. severity: error
select count(*) as failures from ( select game_id as unique_field, count(*) as n_records from main.flames_games where game_id is not null group by game_id having count(*) > 1 ) dbt_test
Run history
The last 6 production runs, newest first. Kept in manifest.json.
| Run | Trigger | Rows in | Duration | Tests |
|---|---|---|---|---|
| Oct 7, 2026, 8:25 p.m. | Local | 67,078 | 0.6 s | ✓ 9 pass |
| Oct 7, 2026, 7:48 p.m. | Local | 67,078 | 0.7 s | ✓ 9 pass |
| Oct 7, 2026, 7:46 p.m. | Local | 67,078 | 0.7 s | ✓ 9 pass |
| Oct 7, 2026, 6:59 p.m. | Local | 67,078 | 0.6 s | ✓ 9 pass |
| Oct 7, 2026, 6:24 p.m. | Local | 67,078 | 3.8 s | ✓ 9 pass |
| Oct 7, 2026, 6:22 p.m. | Local | 67,078 | 4.0 s | ✓ 9 pass |
Data contract
What consumers of this data can rely on. The tests above enforce every line of it.
- Owner
- Cody Chandler
- Refresh
- Weekly, Mondays at 10:00 UTC, via Airflow on GitHub Actions
- Freshness SLA
- 8 days
- Keys
- flames_games: game_idflames_shots: game_id
- Consumers
- Lab dashboards, Ask the data (Claude-generated SQL)
- Source
- NHL public API · schedules and play-by-playData © NHL. Used here for non-commercial illustration.
- Regular-season games only. Preseason and playoffs are excluded.
- Shot coordinates are in feet, with the shooter always attacking x = 89.
- Shot-level goals reconcile to the official final score for every game.
- A failing error-level test blocks the refresh. The last good snapshot keeps serving.
Served files
Gold models written as Parquet and committed, so a deploy never depends on a third-party API.
- flames-games.parquet413 rows · 5 KB
sha256 71698581bd08d957…
- flames-shots.parquet49,793 rows · 287 KB
sha256 b89a7d89ac65d17b…