Data Modeling: Teams, Seasons, Gameweeks & Fixtures
Premier League Predictor: Astro
Chapter 2 · Data Modeling: Teams, Seasons, Gameweeks & Fixtures
Everything else in this course — the admin tools, the fixture-entry UI, predictions, results, both league tables, promotion and relegation — sits on top of the schema this chapter builds. Five real tables: teams, seasons, season_teams, gameweeks, and fixtures, written as raw SQL rather than through an ORM, since better-sqlite3 is a thin, direct SQLite driver with no object-relational layer sitting on top of it. Predictions themselves are deliberately left out of this chapter — that's a genuinely separate concern, covered on its own in Chapter 5.
Team & Season: What Persists vs. What's Real Per Season
A real football club doesn't stop existing the season it gets relegated. teams holds every team this app has ever tracked — promoted, relegated, or currently playing, it doesn't matter — because a team's own identity, name, and history are genuinely permanent facts, not something tied to a single season.
seasons represents one real Premier League season — "2026/27," with a start date, an end date, and an is_current flag so the app always knows which season is the active one.
season_teams: The 20 Teams Actually Competing
Which 20 teams are actually in the Premier League changes every season — three go down, three come up. That's a genuinely different fact from "this team exists," so it gets its own table rather than a column on teams itself: season_teams, a join table recording which teams are competing in which season.
season_teams rows for the current season. Chapter 9's promotion/relegation logic, at the end of a season, is the same table on the other end: remove the bottom three teams' own season_teams rows for next season, add three new rows for the promoted teams. teams itself never changes in either operation — only which teams are marked as competing in a given season does.
Gameweek & Fixture: The Real Schema
A real season has 38 gameweeks; each gameweek has (normally) 10 fixtures, one per pair of the 20 competing teams. The whole schema, written once as plain SQL and run at startup:
foreign_keys=[...] argument on home_team and away_team, because its ORM layer tries to infer which relationship follows which foreign key, and two foreign keys pointing at the same table make that inference genuinely ambiguous. That whole problem simply doesn't exist here. home_team_id and away_team_id are just two plain columns, each with its own explicit REFERENCES teams(id) clause, read directly off the row with no relationship-inference step sitting in between. Nothing is being guessed, so there's nothing to disambiguate — a genuinely different consequence of a genuinely different kind of database layer, not a coincidence.
REFERENCES clause above is declared correctly, but SQLite — unlike PostgreSQL, which always enforces foreign keys by default — genuinely ignores foreign key constraints on every connection until PRAGMA foreign_keys = ON is explicitly run against that connection. Skip it, and inserting a fixture with a home_team_id that doesn't exist in teams at all will silently succeed, with the broken reference only surfacing later, whenever something tries to join against the missing row. This is set once, in the connection module below, before the schema is even created.
The Database Connection Module
One shared module opens the database, turns on foreign key enforcement, and creates every table above if it doesn't already exist — imported by every API route that needs it, rather than each route managing its own connection:
better-sqlite3 deliberately isn't — db.prepare(sql).get(...), .all(...), and .run(...) all execute immediately and return a real value directly, with no promise and no await anywhere in the database layer itself. Every API route built from Chapter 3 onward still declares its GET/POST function as async, since that's what Astro's own APIRoute type expects — but the actual database calls inside those functions run synchronously underneath, finishing before the next line of code even starts.
Nullable Scores: A Fixture Exists Before It's Played
home_score and away_score are both left off NOT NULL, and status defaults to 'scheduled'. A fixture row is created the moment it's added to a gameweek — Chapter 4's own click-to-pair UI — long before it's actually played. Entering a result in Chapter 6 isn't an INSERT at all; it's an UPDATE against a fixture row that has existed since the gameweek was first set up, filling in the two score columns and flipping status to 'played'.
CHECK (home_team_id != away_team_id) stops a team from playing itself — that's a genuine per-row check SQLite can enforce directly, the same as it would in PostgreSQL. A team appearing twice in the same gameweek — once as home in one fixture, once as away in a different fixture — is a different kind of rule entirely: it spans multiple rows, comparing every fixture in a gameweek against every other one. A single-row CHECK constraint can't express that in SQLite any more than it could in Postgres. This schema leaves it to the application layer instead: Chapter 4's own fixture-entry UI removes a team from the list of clickable options the moment it's already been used somewhere in that gameweek, so the rule is enforced by what the interface lets you click, not by the database refusing an insert after the fact.
gameweek_id is just a plain column on fixtures, moving a fixture to a different gameweek is technically nothing more than updating one column on one row — the schema itself puts up no resistance. What's still genuinely undecided is the actual workflow for doing that safely in this app (should predictions already made against the old gameweek carry over, get cleared, or something else?) — a real open question, not something this chapter needs to resolve to keep building.
TypeScript Row Types
Following the same two-layer discipline this course's own siblings use — the schema describes what's actually stored, a set of plain interfaces describes what a row looks like once it comes back out of the database:
is_current stays a raw number at this layer, matching exactly what SQLite actually stores and hands back. Any code that wants a real boolean converts it explicitly — Boolean(season.is_current) — rather than pretending the database column was ever a true boolean type to begin with.
Where This Course Is Headed
Managing the 20 competing teams each season — real CRUD against season_teams (Chapter 3); the fast click-to-pair fixture-entry UI that creates fixtures rows against this chapter's own schema (Chapter 4); recording all four prediction sources per fixture (Chapter 5); entering results — the real UPDATE this chapter set up (Chapter 6); the real league table (Chapter 7); the prediction league table (Chapter 8); and promotion/relegation, operating on season_teams exactly as previewed above (Chapter 9).
Hands-On Exercises
Explain why "which 20 teams are competing this season" is modeled as a separate season_teams table rather than a column on teams itself, and name the two later chapters that operate directly on season_teams rows.
📄 View solutionExplain why fixtures' home_team_id and away_team_id columns don't need anything like SQLAlchemy's foreign_keys=[...] argument in this raw-SQL, better-sqlite3 schema, even though both columns reference the same teams table.
📄 View solutionDemonstrate the real consequence of forgetting PRAGMA foreign_keys = ON: insert a fixture row referencing a team ID that doesn't exist, once against a database connection where the pragma was never set, and once against one where it was — then write down exactly what happens in each case.
📄 View solutionChapter 2 Quick Reference
- teams — every team ever tracked, permanent regardless of promotion/relegation
- seasons — one real Premier League season, with an is_current flag stored as 0/1
- season_teams — the join table recording which 20 teams compete in a given season; Chapters 3 and 9 both operate directly on it
- gameweeks — 1 through 38 per season, unique per (season_id, number)
- fixtures — home_team_id/away_team_id both reference teams(id) directly, with no relationship-inference layer to disambiguate, unlike the SQLAlchemy sibling
- Real gotcha — SQLite ignores foreign keys entirely until PRAGMA foreign_keys = ON is run per connection, set once in db.ts before the schema is created
- better-sqlite3 is synchronous — .get()/.all()/.run() return real values directly, no await needed inside the database layer itself
- Nullable home_score/away_score — a fixture exists before it's played; Chapter 6 fills these in via UPDATE, not INSERT
- Real limit — "no team plays twice in one gameweek" is enforced by the UI (Chapter 4), not a database constraint, unlike "a team can't play itself" (CHECK)
- Still open — the real workflow for moving a fixture to a different gameweek after it's already been entered
- Next chapter: Admin — managing the 20 competing teams each season