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.

fixture:{id}:predictions # one hash per fixture user -> "2-1" expert -> "1-1" ai -> "2-2" guest:Alan -> "3-0" # guests are just more fields, one per guest guest:Bea -> "2-0"

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:

key = f"fixture:{fixture_id}:predictions" await r.hset(key, "user", "2-1") # returns 1 await r.hset(key, "expert", "1-1") # returns 1 await r.hset(key, "guest:Alan", "3-0") # returns 1 await r.hset(key, "guest:Bea", "2-0") # returns 1 await r.hset(key, "ai", "2-2") # returns 1 await r.hgetall(key) # {'user': '2-1', 'expert': '1-1', 'guest:Alan': '3-0', 'guest:Bea': '2-0', 'ai': '2-2'}
Verified: "One Prediction Per Source" Comes Free
A hash cannot hold two fields with the same name, so correcting the user's prediction before kickoff needs no special handling at all:
await r.hset(key, "user", "3-1") # returns 0 -- the field already existed await r.hlen(key) # still 5 -- no second "user" entry, ever
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.
StackHow "one prediction per source per fixture" is enforced
PostgreSQL siblingA partial unique index that deliberately excludes guest rows
Django & MySQL siblingA GeneratedField that evaluates to NULL for guests, exploiting NULL's not-equal-to-NULL rule
Astro (SQLite) siblingA 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.

Same-Name Guests Collide Silently
Uniqueness by field name is only as good as the names. If two different guests are both entered as 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:

await r.hset(key, "user", "banana") # returns 0 -- accepted without complaint await r.hget(key, "user") # 'banana'

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:

from pydantic import BaseModel, Field class PredictionIn(BaseModel): source: str = Field(pattern=r"^(user|expert|ai|guest:[A-Za-z ]{1,30})$") home: int = Field(ge=0, le=20) away: int = Field(ge=0, le=20) # verified: {"home": 2, "away": 1} -> accepted # verified: {"home": -1, ...} -> "Input should be greater than or equal to 0" # verified: {"home": "x", ...} -> "Input should be a valid integer..."

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:

Verified: An Orphan Predictions Hash
await r.exists("fixture:999") # 0 -- no such fixture await r.hset("fixture:999:predictions", "user", "1-0") # 1 -- accepted await r.exists("fixture:999:predictions") # 1 -- a real, permanent hash now exists await r.exists("fixture:999") # 0 -- the fixture still does not
A relational foreign key would have refused the row. Here the write succeeds and leaves an orphan that a scoring job in Chapter 6 might one day trip over.

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:

async def naive_predict(r, fixture_id, source, value): score = await r.hget(f"fixture:{fixture_id}", "home_score") if score not in (None, ""): return "REJECTED" await r.hset(f"fixture:{fixture_id}:predictions", source, value) return "ACCEPTED"

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:

Verified: The Gap Is Real
check: home_score is empty -> looks open # --- meanwhile, the result 2-1 is entered --- write: HSET expert "0-0" naive predict: ACCEPTED result now: ['2', '1'] expert's stored: '0-0' # a prediction recorded after the result was known
The check and the write are two separate commands, and anything can land between them.

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.

PREDICT_LUA = """ local hs = redis.call('HGET', KEYS[1], 'home_score') if hs and hs ~= '' then return 0 end -- locked: a result exists redis.call('HSET', KEYS[2], ARGV[1], ARGV[2]) return 1 -- written """ predict = r.register_script(PREDICT_LUA) await predict(keys=[f"fixture:{fid}", f"fixture:{fid}:predictions"], args=["expert", "4-4"])
Verified: Atomic, and It Locks Correctly
no result yet: returns 1 expert stored: '4-4' result 2-1 entered: returns 0 expert stored: '4-4' # the late write '9-9' was refused
The script's read of 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.
The Script Inherits Cost Two
Run against a fixture that was never created, the script reads 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 or Lua?
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:

async with r.pipeline(transaction=False) as pipe: for fid in fixture_ids: pipe.hgetall(f"fixture:{fid}:predictions") all_predictions = await pipe.execute()
Measured: 10 Fixtures, Same Results
Fetching the predictions for 10 fixtures, median of 30 runs each: 5.95 ms one call at a time versus 0.93 ms pipelined — about 6.4× faster, with identical results. Treat the exact figures as specific to this machine (Redis in a Docker container on Windows, over loopback); the shape of the result — fewer round trips, less waiting — is the point, and it grows with real network latency.

transaction=False is deliberate: this is a read-only batch, and there is no need to wrap it in MULTI/EXEC.

The Reverse Query Has No Index
"Give me every prediction the expert made this season" is easy in SQL and awkward here. There is no index on the field name, so the options are a pipelined 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

Exercise 1

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.

📄 View solution
Exercise 2

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

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

Chapter 5 Quick Reference

  • One hash per fixture, one field per source — fixture:{id}:predictions, guests as guest:<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"