live
./docs / database-schema

Database Schema

Provider: Neon PostgreSQL
ORM: Drizzle
Schema source: apps/api/src/db/schema/
Migrations: apps/api/drizzle/migrations/ (generated SQL files)


Conventions

  • All primary keys are SERIAL (auto-increment integer)
  • All timestamps are TIMESTAMPTZ DEFAULT now()
  • Lap times stored as INTEGER milliseconds — never float
  • Feature scores stored as NUMERIC(6,5) — range 0.00000 to 1.00000
  • All natural keys have UNIQUE constraints

Table Overview

seasons
  └── teams          (season-scoped)
  └── drivers        (season-scoped, FK → teams)
  └── races          (FK → circuits)
        └── qualifying_results      (FK → drivers)
        └── race_results            (FK → drivers)
        └── race_control_messages   (no driver FK — driver_number is a raw OpenF1 car number)
        └── race_overtakes          (two FKs → drivers: overtaking_driver_id, overtaken_driver_id)
        └── team_radio_clips        (FK → drivers)
        └── lap_times               (FK → drivers)
        └── driver_prediction_features (FK → drivers)
        └── race_predictions        (FK → drivers)
        └── sprint_results          (FK → drivers)  ← sprint weekends only
        └── sprint_lap_times        (FK → drivers)  ← sprint weekends only
        └── driver_sprint_features  (FK → drivers)  ← sprint weekends only
        └── sprint_predictions      (FK → drivers)  ← sprint weekends only
  └── driver_season_stats  (FK → drivers)
  └── team_season_stats    (FK → teams)

circuits      (static, seeded once)
data_quality_runs      (FK → races, race_id nullable for season aggregates)
  └── data_quality_issues  (FK → runs)

Tables

seasons

One row per calendar year.

ColumnTypeNotes
idserial PK
yearinteger UNIQUEe.g. 2026

circuits

Static track data, seeded once. Not season-scoped.

ColumnTypeNotes
idserial PK
circuit_keyvarchar(50) UNIQUEe.g. monza, silverstone
namevarchar(100)
countryvarchar(50)
cityvarchar(50)
lap_countinteger
track_length_kmnumeric(5,3)
overtake_ratenumeric(4,3)0.0–1.0; used in prediction model
number_of_cornersintegernull for pre-DRS-era circuits
drs_zonesintegernull for pre-DRS-era circuits
sc_probabilitynumeric(4,3)Historical SC deployment rate (completed races); null for circuits with no completed races
image_urlvarchar(512)Public R2 URL or custom path for track layout image
track_categoryvarchar(30)street | high_speed | high_downforce | balanced — drives car_performance_at_circuit blend

teams

Season-scoped — a team row exists per year it competed.

ColumnTypeNotes
idserial PK
season_idFK → seasons
team_keyvarchar(50)snake_case, e.g. red_bull
namevarchar(100)Display name
nationalityvarchar(50)
UNIQUE(season_id, team_key)

drivers

Season-scoped — a driver row exists per year they raced.

ColumnTypeNotes
idserial PK
season_idFK → seasons
team_idFK → teams
driver_numberinteger
codechar(3)e.g. VER, HAM
first_namevarchar(50)
last_namevarchar(50)
nationalityvarchar(50)
headshot_urlvarchar(255)Populated from FastF1 for 2018+
UNIQUE(season_id, driver_number)

races

One row per race event. Sprint weekends have one race row (covering both the sprint and grand prix).

ColumnTypeNotes
idserial PK
season_idFK → seasons
circuit_idFK → circuits
round_numberinteger
namevarchar(100)e.g. Monaco Grand Prix
race_datedateGrand Prix date
qualifying_datedateSaturday qualifying date
sprint_datedateSprint race date (sprint weekends only)
sprint_qualifying_datedateSQ session date (sprint weekends only)
event_formatvarchar(30)conventional | sprint | sprint_qualifying | sprint_shootout
statusvarchar(30)See status flow below
weathervarchar(30)dry | wet | mixed — main race weather
safety_car_lapsinteger2018+ only
vsc_lapsinteger2018+ only
air_temp_avgnumeric(4,1)2018+ only
track_temp_avgnumeric(4,1)2018+ only
humidity_avgnumeric(4,1)2018+ only
sprint_weathervarchar(30)Sprint-specific weather — sprint weekends only
sprint_safety_car_lapsintegerSprint SC laps
sprint_vsc_lapsintegerSprint VSC laps
sprint_air_temp_avgnumeric(4,1)Sprint session air temp
sprint_track_temp_avgnumeric(4,1)Sprint session track temp
sprint_humidity_avgnumeric(4,1)Sprint session humidity
UNIQUE(season_id, round_number)

Race Status Flow

conventional weekend:
  scheduled → qualifying_done → completed

sprint weekend:
  scheduled → sprint_qualifying_done → sprint_done → qualifying_done → completed
                      ↓                     ↓                ↓
               sprint features      ingest sprint       main qualifying
               sprint predictions   sprint season stats features + predictions

qualifying_results

One row per driver per main qualifying session.

ColumnTypeNotes
idserial PK
race_idFK → races
driver_idFK → drivers
grid_positionintegerFinal starting grid position
q1_time_msintegerNull if knocked out before Q2
q2_time_msintegerNull if knocked out before Q3
q3_time_msintegerNull if not in Q3
sector1_msintegerBest sector times — 2018+ only
sector2_msinteger
sector3_msinteger
speed_stnumeric(5,1)Speed trap km/h — 2018+ only
UNIQUE(race_id, driver_id)

race_results

One row per driver per grand prix.

ColumnTypeNotes
idserial PK
race_idFK → races
driver_idFK → drivers
finish_positionintegerNull = DNF/DSQ
grid_positioninteger
pointsnumeric(4,1)Championship points scored
statusvarchar(30)Finishers: Finished, Lapped, +1 Lap, +2 Laps; DNFs: Retired, Accident, Engine, Collision, Disqualified, Did not start, etc.
total_race_time_msbigintFor winner only
fastest_lapboolean
UNIQUE(race_id, driver_id)

race_control_messages

One row per flag/safety-car/incident event per race, sourced from OpenF1 — 2023+ only (OpenF1 has no coverage before that), and only ingested once a race is completed and past OpenF1’s live-data window.

ColumnTypeNotes
idserial PK
race_idFK → races
datetimestamptzMessage timestamp, from OpenF1
categoryvarchar(30)Flag, SafetyCar, Drs, SessionStatus, CarEvent, Other
flagvarchar(20)e.g. YELLOW, DOUBLE YELLOW, RED, GREEN; null for non-flag messages
lap_numberintegerNullable
driver_numberintegerRaw OpenF1 car number, not a drivers FK — no reliable per-season mapping stored here
scopevarchar(20)e.g. Track, Sector, Driver; nullable
sectorintegerNullable
messagetextRaw OpenF1 message text
UNIQUE(race_id, date, message)

race_overtakes

One row per on-track position exchange per race, sourced from OpenF1 — same 2023+/completed/ past-live-window coverage as race_control_messages. Unlike that table, OpenF1’s driver numbers here are resolved to drivers.id at ingest time (via build_driver_number_map), since both sides of the exchange are needed for a usable UI.

ColumnTypeNotes
idserial PK
race_idFK → races
datetimestamptzOvertake timestamp, from OpenF1
overtaking_driver_idFK → driversThe driver who gained the position
overtaken_driver_idFK → driversThe driver who lost the position
positionintegerTrack position after the overtake
UNIQUE(race_id, date, overtaking_driver_id, overtaken_driver_id)

team_radio_clips

One row per team radio clip per race, sourced from OpenF1 — same 2023+/completed/ past-live-window coverage as the other OpenF1 tables. F1 doesn’t release radio for every session, so sparse or missing coverage for a given race is expected, not a data gap.

ColumnTypeNotes
idserial PK
race_idFK → races
driver_idFK → driversResolved from OpenF1’s car number via build_driver_number_map
datetimestamptzClip timestamp, from OpenF1
recording_urltextDirect link to F1’s own audio CDN
UNIQUE(race_id, driver_id, date)

lap_times

One row per lap per driver — 2018+ only.

ColumnTypeNotes
idbigserial PKLarge table — bigserial not serial
race_idFK → races
driver_idFK → drivers
lap_numberinteger
lap_time_msinteger
sector1_msinteger
sector2_msinteger
sector3_msinteger
speed_stnumeric(5,1)Speed trap km/h
compoundvarchar(20)SOFT, MEDIUM, HARD, INTER, WET
tyre_lifeintegerLaps on current tyre
fresh_tyreboolean
is_pit_lapbooleanExcluded from pace calculations
stint_numberintegerStint index within the race — used for tyre degradation slope
UNIQUE(race_id, driver_id, lap_number)

sprint_results

One row per driver per sprint race. Also stores sprint qualifying (SQ) session times.

ColumnTypeNotes
idserial PK
race_idFK → races
driver_idFK → drivers
finish_positionintegerNull = DNF/DSQ
grid_positionintegerSet from SQ result
pointsnumeric(4,1)Sprint points scored
statusvarchar(30)e.g. Finished, +1 Lap
total_sprint_time_msbigintFor sprint winner only
fastest_lapboolean
sq1_time_msintegerSQ1 lap time — null if eliminated in SQ1
sq2_time_msintegerSQ2 lap time — null if not in SQ2
sq3_time_msintegerSQ3 lap time — null if not in SQ3
sq_sector1_msintegerBest S1 from SQ session
sq_sector2_msintegerBest S2 from SQ session
sq_sector3_msintegerBest S3 from SQ session
sq_speed_stnumeric(5,1)Max speed trap from SQ session
UNIQUE(race_id, driver_id)

sprint_lap_times

Per-lap data for sprint races — mirrors lap_times structure. 2018+ only.

ColumnTypeNotes
idbigserial PK
race_idFK → races
driver_idFK → drivers
lap_numberinteger
lap_time_msinteger
sector1_msinteger
sector2_msinteger
sector3_msinteger
speed_stnumeric(5,1)
compoundvarchar(20)
tyre_lifeinteger
fresh_tyreboolean
is_pit_lapboolean
stint_numberintegerStint index within the sprint
UNIQUE(race_id, driver_id, lap_number)

fp2_long_run_times

FP2 long-run stint data per driver per race. Populated by ingest_fp2. 2018+ only.

ColumnTypeNotes
idserial PK
race_idFK → races
driver_idFK → drivers
compoundvarchar(20)SOFT, MEDIUM, HARD
session_typevarchar(4)FP2 (primary) | FP1 (sprint-weekend fallback — those weekends have no FP2)
median_lap_msintegerMEDIUM-normalised median stint lap time (Soft +500ms, Hard −400ms)
stint_lengthintegerNumber of laps in the long-run stint (≥5)
fp2_best_lap_msintegerDriver’s single fastest raw lap in FP2
UNIQUE(race_id, driver_id, compound)Best (shortest) stint per compound kept

driver_season_stats

Aggregated after each race via compute_season_stats. Includes sprint-specific aggregates.

ColumnTypeNotes
season_idFK → seasons
driver_idFK → drivers
races_enteredintegerGrand prix count
winsintegerGrand prix wins
podiumsinteger
polesintegerGrid position = 1
total_pointsnumericGrand prix points
championship_positionintegerRanked by total_points
win_ratenumeric(5,4)Bayesian smoothed
avg_position_gainnumeric(4,2)grid_position − finish_position avg
dnf_ratenumeric(4,3)dnf_count / races_entered
avg_sector1/2/3_msintegerMedian — 2018+ only
teammate_quali_deltanumeric(6,4)Mean delta vs teammate across season
sprint_races_enteredintegerSprint race count
sprint_winsinteger
sprint_podiumsinteger
sprint_total_pointsnumericSprint points
sprint_win_ratenumeric(5,4)Bayesian smoothed sprint win rate
UNIQUE(season_id, driver_id)

team_season_stats

Aggregated after each race. Drives car_performance_score in predictions.

ColumnTypeNotes
season_idFK → seasons
team_idFK → teams
car_performance_scorenumeric(5,4)Blend of robust race pace (median finish) + qualifying pace (avg grid): 0.6·minmax(21−median) + 0.4·minmax(21−avg_grid)
reliability_scorenumeric(5,4)Normalized from 1 − dnf_rate
championship_positioninteger
UNIQUE(season_id, team_id)

driver_prediction_features

One row per driver per grand prix race. Written by compute_features, updated by compute_predictions.

ColumnTypeNotes
race_idFK → races
driver_idFK → drivers
car_performance_scorenumeric(6,5)0–1
driver_rating_scorenumeric(6,5)
starting_position_scorenumeric(6,5)
win_rate_scorenumeric(6,5)
luck_factor_scorenumeric(6,5)
weather_impact_scorenumeric(6,5)
track_overtake_scorenumeric(6,5)Deprecated — always NULL in weighted-v3; value baked into circuit-adjusted scores
position_gain_scorenumeric(6,5)Raw avg position gain (kept for display only)
long_run_pace_scorenumeric(6,5)Practice (FP2/FP1) primary, historical fallback
reliability_scorenumeric(6,5)
qualifying_delta_scorenumeric(6,5)Rolling 5-race weighted teammate delta
sector_strength_scorenumeric(6,5)
tyre_deg_scorenumeric(6,5)REGR_SLOPE-derived degradation score — lower slope = higher score
circuit_adj_start_pos_scorenumeric(6,5)Starting position scaled by overtake_rate × sc_probability
circuit_adj_position_gain_scorenumeric(6,5)Position gain scaled by overtake_rate
raw_weighted_scorenumeric(8,6)Weighted sum before softmax
long_run_used_fpbooleanWhether long_run_pace_score came from a practice session (FP2 or FP1 fallback; true) or the historical circuit fallback (false/null)
win_probabilitynumeric(6,5)After softmax — sums to 1.0 per race
predicted_positioninteger1 = predicted winner
UNIQUE(race_id, driver_id)

race_predictions

One row per grand prix — the single predicted winner.

ColumnTypeNotes
race_idFK → races UNIQUE
predicted_winner_idFK → drivers
computed_attimestamptz
model_versionvarchar(20)weighted-v3

driver_sprint_features

One row per driver per sprint weekend. Written by compute_sprint_features, updated by compute_sprint_predictions.

ColumnTypeNotes
race_idFK → races
driver_idFK → drivers
car_performance_scorenumeric(6,5)0–1
starting_position_scorenumeric(6,5)Raw SQ grid score (kept for display)
driver_rating_scorenumeric(6,5)Sprint-specific when ≥3 sprint races recorded
track_overtake_scorenumeric(6,5)Deprecated — always NULL in sprint-v2
short_run_pace_scorenumeric(6,5)Best SQ lap time, falls back to main qualifying
weather_impact_scorenumeric(6,5)Based on sprint_weather field; cross-season
win_rate_scorenumeric(6,5)Sprint-specific when ≥3 sprint races recorded
luck_factor_scorenumeric(6,5)Rolling 5-race delta — cross-season
circuit_adj_start_pos_scorenumeric(6,5)SQ grid scaled by overtake_rate × sc_probability
sq_qualifying_delta_scorenumeric(6,5)Rolling 5-sprint SQ teammate delta
raw_weighted_scorenumeric(8,6)
win_probabilitynumeric(6,5)After softmax
predicted_positioninteger
UNIQUE(race_id, driver_id)

sprint_predictions

One row per sprint weekend — the single predicted sprint winner.

ColumnTypeNotes
race_idFK → races UNIQUE
predicted_winner_idFK → drivers
computed_attimestamptz
model_versionvarchar(20)sprint-v2

data_quality_runs

One row per audit pass (season aggregate) or per-race audit. Written by data_quality_audit.

ColumnTypeNotes
idserial PK
yearintegerSeason audited (0 when an all-years scan ran)
race_idFK → races, nullableNull = season aggregate pass
generated_attimestamptz
health_scorenumeric(5,2)0–100, per-pass average
summaryjsonbraces_audited, issue_count, fixable_count, by_severity, by_table

data_quality_issues

One row per flagged gap. Written by data_quality_audit.

ColumnTypeNotes
idserial PK
run_idFK → data_quality_runs
race_idFK → races, nullable
round_numberinteger
yearinteger
table_namevarchar(40)Source table the issue belongs to
check_namevarchar(60)e.g. driver_coverage, lap_coverage, row_count
severityvarchar(10)high | medium | low
detailvarchar(500)Human-readable summary
fixablebooleanWhether re-ingest/recompute can close it
is_sprintbooleanSprint-weekend issue
resolvedbooleanManual triage flag (default false)
created_attimestamptz