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.

This is exactly what Chapter 3 and Chapter 9 operate on
Chapter 3's own admin tooling for managing "the 20 competing teams each season" is really just CRUD against 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

# predictor/models.py from django.db import models class Team(models.Model): name = models.CharField(max_length=100, unique=True) short_name = models.CharField(max_length=20) def __str__(self): return self.name class Season(models.Model): name = models.CharField(max_length=20, unique=True) # e.g. "2026/27" start_date = models.DateField() end_date = models.DateField() is_current = models.BooleanField(default=False) def __str__(self): return self.name class SeasonTeam(models.Model): season = models.ForeignKey(Season, on_delete=models.CASCADE, related_name='season_teams') team = models.ForeignKey(Team, on_delete=models.CASCADE) class Meta: constraints = [ models.UniqueConstraint(fields=['season', 'team'], name='uq_season_team') ] class Gameweek(models.Model): season = models.ForeignKey(Season, on_delete=models.CASCADE, related_name='gameweeks') number = models.PositiveSmallIntegerField() # 1 through 38 class Meta: constraints = [ models.UniqueConstraint(fields=['season', 'number'], name='uq_season_gameweek_number') ] class Fixture(models.Model): STATUS_SCHEDULED = 'scheduled' STATUS_PLAYED = 'played' STATUS_CHOICES = [(STATUS_SCHEDULED, 'Scheduled'), (STATUS_PLAYED, 'Played')] gameweek = models.ForeignKey(Gameweek, on_delete=models.CASCADE, related_name='fixtures') home_team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='home_fixtures') away_team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='away_fixtures') kickoff_time = models.DateTimeField(null=True, blank=True) home_score = models.PositiveSmallIntegerField(null=True, blank=True) away_score = models.PositiveSmallIntegerField(null=True, blank=True) status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=STATUS_SCHEDULED) class Meta: constraints = [ models.CheckConstraint( check=~models.Q(home_team=models.F('away_team')), name='ck_fixture_teams_differ', ) ]
related_name is required here — Django's own version of the FastAPI course's foreign_keys=[...] fix
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.
A genuinely MySQL-specific gotcha this course's own PostgreSQL sibling never faces
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 vs. blank — two genuinely separate Django concerns
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.
One real constraint this schema still can't enforce on its own
A team appearing twice in the same gameweek — once as home in one fixture, once as away in a different fixture — spans multiple rows, not one. Neither 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

python manage.py makemigrations predictor python manage.py migrate

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

Exercise 1

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 solution
Exercise 2

Explain 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 solution
Exercise 3

Run 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 solution

Chapter 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