Predictions
Premier League Predictor: FastAPI & Redis
Chapter 5 · Predictions
Fixtures exist and can be entered quickly and safely. Now the app's real point: recording who predicted what. Every fixture can carry up to four kinds of prediction — the user's own, the BBC expert's, one or more guests', and the BBC's AI — and they need to be stored so that a later chapter can score each one against the real result. This is also the chapter where Redis has a genuine structural advantage over a relational database, and where it has three more of the costs Chapter 2 warned about.
The Model: One Hash Per Fixture, One Field Per Source
A relational design gives predictions their own table with one row per (fixture, source) pair, then needs a constraint to stop two rows for the same pair. Redis offers a shape that makes the constraint unnecessary: a single hash per fixture, with each prediction source as a field in it and the scoreline as the value.
This follows Chapter 2's naming convention: the fixture's own fields stay in
fixture:{id}, and the predictions live beside it under a related key. Writing a
prediction is a single HSET against one field:
HSET returns the number of new fields it created: 1 the first time
a source predicts, 0 when it overwrote an existing prediction. That single number lets
the route answer "created" versus "corrected" for free. Compare what the sibling stacks needed to
reach the same guarantee — this is the chapter's genuine simplicity win.
| Stack | How "one prediction per source per fixture" is enforced |
|---|---|
| PostgreSQL sibling | A partial unique index that deliberately excludes guest rows |
| Django & MySQL sibling | A GeneratedField that evaluates to NULL for guests, exploiting NULL's not-equal-to-NULL rule |
| Astro (SQLite) sibling | A partial unique index, plus a hand-written upsert |
| This course (Redis) | Nothing extra — a hash field is unique by definition, and guests differ by field name |
The guest case that needed special constraint tricks in every relational sibling is a non-issue
here: guest:Alan and guest:Bea are simply different field names. Averaging
several guests into one comparable figure is still a Chapter 6 problem — and, as the relational
siblings established, it is points that get averaged, not scorelines.
guest:Alan, the second HSET returns 0, replaces the first
person's prediction, and raises nothing. Exercise 1 reproduces it. A relational sibling would key on
a guest row's own ID; here, the key discipline has to come from the code.
Cost One: Redis Validates Nothing
A prediction is really two non-negative integers, but the hash stores an opaque string:
Nothing at the Redis layer knows a score has a shape, so the only place validation can live is the
FastAPI route. Pydantic does the job, and it is worth being explicit that here it is the
only line of defence, not a second one behind a CHECK constraint:
The route validates first and only then calls HSET with
f"{body.home}-{body.away}". Anything that writes to Redis without going through the
route — a script, a console session, a future feature — bypasses that validation completely.
Cost Two: A Prediction Can Target a Fixture That Doesn't Exist
This is Chapter 2's missing-foreign-key finding in a new place. The predictions hash lives under its own key, so writing to it never touches the fixture's key at all:
Cost Three: Locking Predictions Once a Result Exists
A prediction made after the real score is known is worthless, so the route should refuse one once the fixture has a result. The natural first version reads the fixture's score, then writes:
That is Chapter 4's check-then-act shape again. Rather than hoping two real requests collide, this chapter forces the bad interleaving deterministically — the result gets entered in the gap between the check and the write:
The Fix: A Small Lua Script
Chapter 4 used WATCH for this class of problem. Redis offers a second tool that suits
"check one key, write another, all-or-nothing": a Lua script sent with EVAL. Redis runs
a script as a single atomic unit, so nothing can land between its lines.
home_score and its write to the predictions hash cannot be
separated. register_script handles loading the script once and calling it by its hash
afterwards.
home_score as
nil, decides the fixture is open, and happily writes an orphan predictions hash — verified:
it returned 1 and the hash then existed. Locking and existence are separate checks, and
the script only had the first. Exercise 2 adds the second inside the same atomic script.
WATCH (Chapter 4) lets the client do arbitrary logic between reading and writing, at
the price of a retry loop that must re-validate on every pass. A Lua script has no retries, because
it is never interrupted — but the logic must be written in Lua and run inside Redis. For a small,
fixed rule like this one, the script is the tidier fit; for logic that needs Python's own libraries,
WATCH is.
Reading Predictions Back
One fixture's predictions come back in a single HGETALL. Scoring a whole gameweek needs
the predictions for all its fixtures — the IDs come from Chapter 2's
gameweek:{season_id}:{number}:fixtures Set, and each needs its own
HGETALL. Sent one at a time, that is one network round trip per fixture; a pipeline
sends them together:
transaction=False is deliberate: this is a read-only batch, and there is no need to wrap
it in MULTI/EXEC.
HGET per fixture (fine when
the fixture IDs are already known, as they are for a gameweek) or a SCAN across every
fixture:*:predictions key, which touches the whole keyspace. Exercise 3 works through
both.
Hands-On Exercises
Enter two different guests who happen to share the same first name as guest:Alan, one after the other. Report each HSET return value and what HGETALL and HLEN show afterwards, then propose a key scheme that avoids the collision.
Extend the Lua script so it refuses to write when the fixture itself does not exist, returning -1 for that case (0 stays "locked", 1 stays "written"). Run it against a nonexistent fixture, an open fixture, and a locked fixture, and report all three results.
📄 View solutionFetch the expert's prediction across five fixtures using a pipelined HGET, then list the same fixtures' prediction keys using SCAN. Explain why SCAN returns more keys than the five you asked about, and when each approach is the right one.
📄 View solutionChapter 5 Quick Reference
- One hash per fixture, one field per source —
fixture:{id}:predictions, guests asguest:<name>fields - Verified: uniqueness is free — a hash field can't repeat, so no index or GeneratedField is needed; HSET returns 1 for a new prediction and 0 for a correction
- Verified: Redis validates nothing — "banana" was stored; Pydantic in the route is the only defence
- Verified: orphan predictions — writing to a nonexistent fixture's predictions hash succeeds
- Verified: check-then-write gap — a result entered between the check and the write let a late prediction through; a Lua script closes it atomically
- Pipelining — ~6.4× faster for 10 HGETALLs on this machine; no index exists for "all predictions by one source"