Data Modeling: Teams, Seasons, Gameweeks & Fixtures as Django Models
Premier League Predictor: Django & MySQL
Chapter 2 · Data Modeling: Teams, Seasons, Gameweeks & Fixtures as Django Models
Everything else in this course — the admin, the fixture-entry UI, predictions, results, both league tables, promotion and relegation — sits on top of the schema this chapter builds, expressed as real Django models rather than SQLAlchemy's own class-based mapping. Five real tables: Team, Season, SeasonTeam, Gameweek, and Fixture. Predictions themselves are deliberately left out — that's a genuinely separate concern, covered 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. Team holds every team this app has ever tracked, since a team's own identity and history are genuinely permanent facts, not something tied to a single season. Season represents one real Premier League season, with a real start date, end date, and an is_current flag.
SeasonTeam: 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 model rather than a field on Team itself: SeasonTeam, a join table recording which teams are competing in which season.
SeasonTeam rows for the current season, using the Django admin directly. Chapter 9's promotion/relegation logic, at the end of a season, is the same model on the other end: remove the bottom three teams' own SeasonTeam rows for next season, add three new rows for the promoted teams. Team itself never changes in either operation — only which teams are marked as competing in a given season does.
Gameweek & Fixture: The Real Schedule
Fixture has two foreign keys pointing at the same model, Team — home_team and away_team. Without an explicit related_name on each, Django would try to auto-generate a reverse accessor for both using the same default name derived from Fixture — a real clash Django's own system checks catch and refuse to run with ("Reverse accessor... clashes with reverse accessor..."). related_name='home_fixtures' and related_name='away_fixtures' give each foreign key its own distinct reverse accessor (team.home_fixtures.all(), team.away_fixtures.all()) — the exact same underlying ambiguity this site's own FastAPI/SQLAlchemy sibling course resolved with foreign_keys=[home_team_id]/foreign_keys=[away_team_id], hit here at the ORM's own ability to auto-name a reverse relationship rather than at query-building time.
CheckConstraint is real, and Django will happily create it — but MySQL itself only started actually enforcing CHECK constraints as of version 8.0.16, released in 2019. Before that release, MySQL's own documentation is explicit: the CHECK clause was parsed but silently ignored by every storage engine — a team could be recorded as playing itself with no error at all, on an older MySQL install, despite the constraint appearing to exist. Django's own system checks are aware of this and will warn directly ("MySQL does not support check constraints") if it detects an unsupported version; Django 4.2 and later require MySQL 8+ outright, closing most of this gap by requirement rather than leaving it to be discovered. Running a real, current MySQL 8.0.16+ install — matching this course's own Chapter 1 setup — means this constraint genuinely works as written.
Nullable Scores: A Fixture Exists Before It's Played
home_score and away_score are both null=True (allowing a real database NULL) and blank=True (allowing a Django form or the admin to leave the field empty), with status defaulting 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 is a real update against a fixture row that already exists, filling in the two score fields and flipping status to "played".
null=True is a database-level setting: the column itself may store NULL. blank=True is a validation-level setting: a Django ModelForm (or the admin) will accept the field being left empty. Django's own documented convention is that both are usually set together for an optional field like this — a fixture with no result yet needs the database to actually allow the absence and any form filling it in to not demand a value that doesn't exist yet.
CheckConstraint nor UniqueConstraint can express "compare this row against every other row sharing the same gameweek." This schema leaves that rule to the application layer, same as its FastAPI sibling: 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.
Creating and Applying the Migration
makemigrations compares the models above against Django's own migration history and writes a new migration file describing the five new tables and their constraints; migrate actually applies it against the real MySQL database from Chapter 1.
Where This Course Is Headed
Registering these models with the Django admin as the real, fast manual-entry tool for managing the 20 competing teams each season (Chapter 3); the fast click-to-pair fixture-entry UI built outside the admin, creating real Fixture rows against this chapter's own schema (Chapter 4); recording all four prediction sources per fixture, and the real MySQL-specific answer to Chapter 1's own flagged partial-unique-index gap (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 SeasonTeam exactly as previewed above (Chapter 9).
Hands-On Exercises
Explain why Fixture's home_team and away_team fields both need an explicit related_name, and what real error Django's own system checks would raise if one were left out.
📄 View solutionExplain what would actually happen if a team were recorded as playing itself on a MySQL 8.0.15 database with this exact CheckConstraint in place, and why that differs from what would happen on MySQL 8.0.16 or later.
📄 View solutionRun makemigrations and migrate against a real MySQL 8.0.16+ database yourself, then confirm in a MySQL client (e.g. SHOW CREATE TABLE predictor_fixture;) that the CHECK constraint genuinely appears in the generated table definition.
📄 View solutionChapter 2 Quick Reference
- Team — every team ever tracked, permanent regardless of promotion/relegation
- Season — one real Premier League season, with an is_current flag
- SeasonTeam — the join table recording which 20 teams compete in a given season; Chapters 3 and 9 both operate on it
- Gameweek — 1 through 38 per season, unique per (season, number)
- Fixture — home_team/away_team both reference Team, needing distinct related_name values to avoid a real Django reverse-accessor clash
- MySQL-specific gotcha — CheckConstraint only actually enforces on MySQL 8.0.16+; earlier versions silently parse and ignore it
- Nullable home_score/away_score — a fixture exists before it's played; Chapter 6 fills these in via a real update
- Next chapter: The Django admin as the real, fast manual-entry tool for teams & fixtures