← All projects

Personal project · 2026

F1 Analysis Platform

An end-to-end analytics stack on every Formula 1 session since 2023. I built the ingestion, the data model, the analysis and the dashboard, using the same patterns I use at work, applied to a sport I love.

3.1M
raw API records loaded
91
automated dbt data tests
131
dashboard pages
$0
to host and run
Season overview page: championship leaders and points progression, line colours matching each team
Season overview: the championship fight in team colours. Every chart is filterable by season.

Why I built it

At work I build BI on Microsoft's stack. I wanted a project that showed the same skills end to end, in the open, using the analytics-engineering tools that are becoming standard: dbt for modelling and testing, DuckDB as the warehouse, and BI-as-code for the dashboard. Formula 1 is a great dataset for it. It's messy, it's high-volume, and every number can be checked against reality.

Architecture

  1. OpenF1 API16 endpoints, rate-limited
  2. Python extractorIncremental, idempotent
  3. BronzeRaw JSON in DuckDB
  4. Silver → Golddbt: typed staging, star schema, 91 tests
  5. DashboardStatic site, queries run in the browser
A medallion architecture: raw data lands untouched, dbt cleans and models it, and the dashboard only ever reads the tested gold layer.

1 · Extraction

A small Python package pulls the OpenF1 API into a bronze schema in DuckDB. Each record is stored as raw JSON, so when a season adds or drops fields the load never breaks; types are applied later, in dbt.

  • Incremental and idempotent. Data loads per race weekend inside a transaction, and an ingestion log records every outcome. Re-running never duplicates data, and failed loads are retried automatically next run.
  • Rate limits respected. The original notebook version silently lost data when the API throttled it. The rebuilt client paces itself under the limit and retries throttled requests instead of recording them as "no data".
  • 5× fewer API calls by loading a whole weekend per request instead of one session at a time.

2 · Modelling

dbt turns bronze into a silver layer (one typed, deduplicated model per endpoint) and a gold star schema: dimensions for meetings, sessions, drivers and teams; facts for laps, stints, pit stops, results and standings; and aggregate models for the analysis. Two modelling problems were more interesting than they look:

  • Driver identity. Car numbers aren't stable (Lando Norris raced as #4, then #1), so a session-level bridge table resolves each number to a permanent driver key.
  • Team identity. Teams rebrand (AlphaTauri → RB → Racing Bulls), so a small seed table links names into one lineage for multi-season views.

Here's the core of the gold layer: one row per lap in the fact table, joined to conformed dimensions for the session, driver and team. Sessions roll up to race weekends (a small snowflake), so any lap can be sliced by season, circuit or weather.

Swipe sideways to see the full schema →

Gold layer star schema The fct_lap fact table joins to dim_session, dim_driver and dim_team; dim_session joins to dim_meeting. 1 ∞ 1 ∞ 1 ∞ 1 ∞ dim_session DIM · 495 rows session_key PK meeting_key FK session_name session_type date_start had_rain safety_car_deployments + 18 more dim_meeting DIM · 101 rows meeting_key PK year round_number meeting_name circuit_short_name country_name + 12 more dim_driver DIM · 71 rows driver_id PK display_name name_acronym country_code headshot_url first_season + 6 more dim_team DIM · 41 rows team_id PK year team_name constructor_lineage team_colour fct_lap FACT · 256K rows session_key FK driver_id FK team_id FK lap_number lap_duration_s fuel_corrected_lap_s sector_1_s · 2 · 3 compound tyre_age_laps position_at_lap_end gap_to_leader_s is_clean_lap + 16 more columns
The same dimensions also serve:
  • fct_stint
  • fct_pit_stop
  • fct_session_result
  • fct_overtake
  • fct_driver_standing
  • fct_team_standing
  • + 4 agg_ models
Gold layer star schema in dbt. Lap-level facts carry stable driver and team keys, resolved from per-session car numbers during the build.

Beyond 91 automated tests, I validated gold against the real world: final drivers' and constructors' standings match the official results for 2023–2025.

3 · Analysis

The fun part. A few of the methods behind the dashboard:

  • Fuel correction. Cars get about 0.03 s faster per lap as fuel burns off, which hides tyre wear. Raw degradation came out near zero for every compound; after correcting each lap to an end-of-race fuel load, the real ranking appears: Soft 0.046, Medium 0.033, Hard 0.027 seconds lost per lap.
  • Clean laps. Pace comparisons only use representative laps: no lap 1, no pit in/out laps, nothing slower than 107% of the session median (which drops safety-car laps).
  • The ideal lap. Each driver's best sector 1, 2 and 3 from any session of the weekend, compared with their best qualifying lap. Wet qualifying sessions are detected and excluded automatically, so dry practice sectors aren't compared against a wet lap.
Bullet chart comparing each driver's qualifying sector times with their ideal sectors at the 2025 Chinese Grand Prix
Where the time went: qualifying sectors (bars) vs each driver's ideal sectors (ticks). A custom chart I built on ECharts.
Heatmap of each team's gap to the fastest car at every 2026 race
Car performance: each team's gap to the fastest car, race by race.
Race trace showing each driver's gap to the leader by lap at the 2025 Bahrain Grand Prix
Race trace: gaps to the leader, lap by lap. Pit stops and the safety car are easy to spot.
Tyre strategy chart showing each driver's stints by compound at the 2025 Bahrain Grand Prix
Tyre strategy, in Pirelli's compound colours, with drivers in finishing order.

4 · Dashboard

The dashboard is built with Evidence, a BI-as-code tool: every page is a Markdown file with SQL and chart components, version-controlled like any other code. It compiles to a static website. The data ships as compressed Parquet files, and visitors' browsers query it with DuckDB compiled to WebAssembly, so there's no server to run and hosting is free.

  • 131 pages, including one page per Grand Prix and one per driver, generated from templates.
  • Custom components where the built-in charts fell short, such as the sector bullet chart and gradient bar charts.
  • A weekly automated refresh with GitHub Actions: new races are loaded, modelled, tested and published without me touching it.

What the data taught me

  • Validate against reality, not just tests. Every test passed while my first "final standings" query was wrong: session keys don't increase through a season. Checking against the official table caught it.
  • Absent isn't zero. Drivers who missed a race (injured, replaced) still appear in that race's standings, which broke a join until I added a season-level fallback.
  • Know when not to show data. The API's overtake counts include position changes from pit stops, so I left them out rather than publish numbers I couldn't stand behind.

Tech stack

  • Python
  • Requests
  • DuckDB
  • dbt
  • SQL
  • Evidence
  • ECharts
  • Svelte
  • GitHub Actions
  • GitHub Pages

The source code is in a private repository. I'm happy to walk through it. Get in touch.