The Real League Table via Redis Sorted Sets
Premier League Predictor: FastAPI & Redis
Chapter 7 · The Real League Table via Redis Sorted Sets
This is the chapter Redis was chosen for. A league table is a ranked list that changes whenever a result comes in, and a Redis sorted set keeps a ranked list ordered as it changes. Every relational sibling instead computed the table with a query each time it was read. Here the table is maintained: each result updates it as it is entered, and reading it is a single range call. The chapter's real work is making that maintenance correct — a sorted set gives each member exactly one number to sort by, and a league table sorts by three things.
Sorted Set Basics
A sorted set holds unique members, each with a numeric score, and always keeps them ordered by score. Where two members have the same score, Redis orders them by the member string itself, lexicographically. That last rule is quiet and consequential, and the next two sections are about it.
The Naive Table Ranks by Points Only
The direct translation is a set with points as the score. Two teams each win once — team 1 by 3-0, team 2 by 1-0 — so both have 3 points, but team 1's goal difference is better:
ZREVRANGE reverses that too. Team 2 is listed above team 1 even though team 1 has the
better goal difference. Nothing errors; the table is just wrong.
A second trap hides in the same rule. Members are strings, so if the members are plain team IDs,
ties order "10" before "2":
Padding every team ID to three digits ("{id:03d}") makes the string order match the
numeric order. The member is only a label; the team's data is still under team:{id}.
One Score, Three Sort Keys
A Premier League table sorts by points, then goal difference, then goals scored. A sorted set has one score per member, so the three values have to be packed into a single number, most important first:
Adding 500 keeps the goal-difference part non-negative. Points occupy the millions, goal difference
the thousands, goals scored the units, so comparing composites compares them in exactly the right
priority. The score is then negated: with the best team's score the smallest,
ZRANGE (ascending) lists the table from first place down, and ties that survive all
three keys resolve to ascending member order — the padded IDs from the previous section — instead
of the reversed order that ZREVRANGE gives.
Per-Team Stats, and What a Result Changes
The composite covers points, goal difference and goals scored, but the table also shows
played/won/drawn/lost. Those live in one hash per team, team:{id}:stats, with fields
played, won, drawn, lost, gf,
ga. Entering a result has to update both teams' stats and both teams' sorted-set scores.
And it has to survive being repeated or corrected — Chapter 6's lesson, applied again. A result can be
entered twice or fixed later, and simply adding a result's effect every time would double-count it.
The same remedy works: remember what was last applied to this fixture (fixture:{id}:applied),
reverse that, then apply the new result. One Lua script does all of it atomically:
The five keys are passed in: the fixture, its :applied hash, the table's sorted set, and
the home and away stats hashes. The members are the padded IDs (ARGV[1],
ARGV[2]). The route looks up the two team IDs from the fixture hash first — a missing
fixture returns 404 there, before the script runs, so the existence guard from Chapter 6 still holds.
Because this script also writes home_score and away_score, it replaces
Chapter 6's separate result-entry script; the prediction-scoring script stays as it was.
A new season starts by giving every team the zero-played score, -500000
(0 points, goal difference 0, 0 goals scored). All teams then tie and list in padded-ID order, which
is a sensible empty table.
KEYS, which is the form Redis expects.
A script that constructed key names from its arguments would work on a single server but break on a
Redis Cluster, where the keys must be declared up front so Redis can route them together. This course
uses one server, but passing the keys in costs nothing.
Verified Against an Independent Calculation
A hand-picked example proves little for code like this, so the check is a brute-force one. A ten-team double round robin — 90 fixtures — was given random results (0 to 4 goals each side). Then 40 of them were corrected to different random results, and 5 were re-entered with the same result they already had. The table was then compared with one computed from scratch in plain Python from the final results:
That bottom-three line is a preview: Chapter 9's relegation is a single
ZRANGE table -3 -1.
A Field That Isn't There, and One That Is Zero
HINCRBY creates a field on first use, and reversing a correction leaves it at
0 rather than deleting it. Entering a 2-0 result and correcting it to 0-1 produced the same
numbers as entering 0-1 fresh — but not the same hash:
The sorted-set scores and order were identical in both runs. A missing field and a zero mean the same
thing here, so any code reading a stats hash must default to 0
(int(stats.get("won", 0))) rather than assume every field is present.
Reading the Table
The read path is two steps: one range call for the order, then one pipelined batch for the stats — Chapter 5's pipelining, again:
Points and goal difference are recomputed from the stats hash rather than decoded out of the score. The score exists only to sort; the hash is the record.
The Table Is a Derived Index — So It Can Be Rebuilt
Maintaining the table incrementally has a cost: it can drift from the fixtures if anything ever
writes a result without going through the script. The remedy follows from the design. Everything in
the table can be recomputed from the fixtures' stored results, and the script is idempotent, so a
rebuild is: delete the table and stats and applied keys, reset every team to -500000,
replay every fixture that has a result.
:applied hashes, then
replaying the 90 stored results through the same script, the order matched the original exactly.
The table is safe to throw away, which is what makes incremental maintenance an acceptable choice
instead of a risky one.
| Stack | How the table is produced | Cost |
|---|---|---|
| PostgreSQL / SQLite siblings | A UNION ALL query over every fixture, run on each read | Always correct; work grows with fixtures on every read |
| Django & MySQL sibling | A hand-written UNION ALL through .raw() | Same as above |
| This course (Redis) | Maintained at write time in a sorted set + stats hashes | Read is one range call plus one pipeline; every write path must go through the script |
For 380 fixtures and 20 teams the read-time query is not slow, so nothing here is forced by performance — this variant exists to show the idiomatic Redis approach honestly, including the discipline it requires. Head-to-head and fair-play tiebreakers are not implemented, same as the relational siblings.
Hands-On Exercises
Build three small cases: two teams level on points and goal difference but different on goals scored; two teams level on all three keys; and two teams level on points but different on goal difference. Report the real table order for each and say which key or fallback decided it.
📄 View solutionCompute the composite score by hand for two teams that collide when goal difference reaches +500 (3 points versus 4 points at -500) and when goals scored reaches 1,000. Report the real numbers, then propose a wider packing that removes both collisions and state what its new limits are.
📄 View solutionEnter a 2-0 result, then correct it to 0-1. Compare the resulting table and stats against entering 0-1 on a fresh table, and report exactly what matches and what differs.
📄 View solutionChapter 7 Quick Reference
- A sorted set has one score per member — a three-key table sort must be packed into one number
- Verified: ties fall back to member string — and
ZREVRANGEreverses it, putting the worse team first - Pad the IDs —
"10"sorts before"2";"{id:03d}"fixes it - composite = points×106 + (GD+500)×103 + GF, stored negated —
ZRANGEthen lists first place down - Verified: the packing collides past ±499 GD or 1,000 GF — safe for a real season, worth a comment
- Reverse the last-applied result, then apply the new one — one atomic Lua script, safe to repeat or correct
- Verified: matches an independent calculation — 90 fixtures, 40 corrections, 5 repeats, all stats and order equal
- Default missing hash fields to 0 — a reversed correction leaves
'won': '0', a fresh entry has no field - The table is derived — delete and replay rebuilds the same order