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:

Verified: Ties Fall Back to Member Name — and Reversed
await r.zincrby("naive", 3, "1") # team 1: won 3-0 await r.zincrby("naive", 3, "2") # team 2: won 1-0 await r.zrevrange("naive", 0, -1, withscores=True) # [('2', 3.0), ('1', 3.0)] -- team 2 above team 1
The scores are equal, so Redis fell back to comparing the member strings, and 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":

members 1, 2, 3, 10, all with the same score: plain ids: ['1', '10', '2', '3'] padded ids: ['001', '002', '003', '010']

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:

composite = points * 1_000_000 + (goal_difference + 500) * 1_000 + goals_for score = -composite # negated -- see below

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.

The Packing Has Limits — Verified
The scheme assumes goal difference stays within ±499 and goals scored below 1,000. Outside that, one field spills into the next and two different teams get the same number:
A: 3 points, goal difference +500 -> 4000000 B: 4 points, goal difference -500 -> 4000000 # identical A: 3 points, goal difference 0, 1000 goals scored -> 3501000 B: 3 points, goal difference +1, 0 goals scored -> 3501000 # identical
A real season is nowhere near those figures — the largest composite in this chapter's scale is about 1.1×108, comfortably inside the 253 range where a double stores integers exactly. But the limits are real and belong in a code comment. Exercise 2 works through them.

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:

APPLY_RESULT_LUA = """ local function bump(stats, gf, ga, s) -- s = +1 to apply, -1 to reverse redis.call('HINCRBY', stats, 'played', s) redis.call('HINCRBY', stats, 'gf', s*gf) redis.call('HINCRBY', stats, 'ga', s*ga) if gf > ga then redis.call('HINCRBY', stats, 'won', s) elseif gf == ga then redis.call('HINCRBY', stats, 'drawn', s) else redis.call('HINCRBY', stats, 'lost', s) end end local function rescore(stats, member) local won = tonumber(redis.call('HGET', stats, 'won') or '0') local drawn = tonumber(redis.call('HGET', stats, 'drawn') or '0') local gf = tonumber(redis.call('HGET', stats, 'gf') or '0') local ga = tonumber(redis.call('HGET', stats, 'ga') or '0') local composite = (3*won + drawn) * 1000000 + (gf - ga + 500) * 1000 + gf redis.call('ZADD', KEYS[3], -composite, member) end local hs, as = tonumber(ARGV[3]), tonumber(ARGV[4]) if redis.call('EXISTS', KEYS[2]) == 1 then -- something was applied before: undo it local oh = tonumber(redis.call('HGET', KEYS[2], 'h')) local oa = tonumber(redis.call('HGET', KEYS[2], 'a')) bump(KEYS[4], oh, oa, -1) bump(KEYS[5], oa, oh, -1) end bump(KEYS[4], hs, as, 1) bump(KEYS[5], as, hs, 1) redis.call('HSET', KEYS[2], 'h', hs, 'a', as) redis.call('HSET', KEYS[1], 'home_score', hs, 'away_score', as) rescore(KEYS[4], ARGV[1]) rescore(KEYS[5], ARGV[2]) return 1 """

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 Built From the Data
The script receives every key it touches through 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:

Verified: Redis Table Equals the From-Scratch Table
order matches independent calculation: True all played/won/drawn/lost/gf/ga match: True bottom three (ZRANGE table -3 -1): ['006', '002', '007'] # same as the independent calculation top three (team, pts, GD, GF): (8, 40, 21, 42) (3, 40, 15, 47) (5, 35, 10, 40)
The top two teams are level on 40 points and separated by goal difference, so the tiebreak is exercised. No two teams tied on all three keys in this run, so the member-order fallback was not tested here; Exercise 1 does test it.

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:

corrected: {'played': '1', 'gf': '0', 'ga': '1', 'won': '0', 'lost': '1'} fresh 0-1: {'played': '1', 'gf': '0', 'ga': '1', 'lost': '1'} # no 'won' field at all

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:

async def get_table(r, table_key): members = await r.zrange(table_key, 0, -1) # best first async with r.pipeline(transaction=False) as pipe: for m in members: pipe.hgetall(f"team:{int(m)}:stats") stats = await pipe.execute() rows = [] for position, (m, s) in enumerate(zip(members, stats), start=1): g = lambda k: int(s.get(k, 0)) # missing field == 0 rows.append({"pos": position, "team_id": int(m), "played": g("played"), "won": g("won"), "drawn": g("drawn"), "lost": g("lost"), "gd": g("gf") - g("ga"), "points": 3 * g("won") + g("drawn")}) return rows

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.

Verified: Delete and Replay Gives the Same Table
After deleting the sorted set, all ten stats hashes and all 90 :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.
StackHow the table is producedCost
PostgreSQL / SQLite siblingsA UNION ALL query over every fixture, run on each readAlways correct; work grows with fixtures on every read
Django & MySQL siblingA hand-written UNION ALL through .raw()Same as above
This course (Redis)Maintained at write time in a sorted set + stats hashesRead 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

Exercise 1

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

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

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

Chapter 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 ZREVRANGE reverses 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 — ZRANGE then 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