# Seven games in 21 days: who plays when?

Source: https://www.footballdatascience.co.uk/learn/fixture-congestion-rotation
Published: 2026-10-01

> A congested run of fixtures and a rule that nobody starts three in a row. Picking the best team every game rests the stars for the cup semi-final. Treating the whole run as one scheduling problem rests them for the right games instead, and shows why they can't play every big one.

**On the terraces:** Seven games in three weeks and the stars can't play them all. Pick your best team every game and they'll be resting for the semi-final. This piece plans the whole run at once, so the big names are fresh for the games that matter most.

## The football question

Seven games in 21 days: league games, a cup semi-final and a European tie. The sports science staff have one rule: **nobody starts three games in a row.** Who should play when?

The instinct is to pick the strongest team for the next game, and worry about the one after when it comes. This part shows what that costs, and what planning the whole run at once looks like.

## The concept

A **scheduling problem** is about what happens when: here, which players start which games, over a stretch of time, with rules that link one game to the next. It's [Part 6](/learn/integer-programming-signings)'s integer programming with a calendar attached: every player in every game is a yes or a no. And like [Part 4](/learn/squad-minutes-linear-programming)'s minutes, it's about sharing out something limited, the players' freshness.

The rule linking the games is what makes it hard. Starting a player today uses up some of his next few days, so a choice in game 1 changes what's possible in game 3.

## A football example

Seven games, each with a made-up weight for how much it matters:

| Game | Competition | Weight |
|---|---|---|
| 1 | League | 1.0 |
| 2 | League | 1.0 |
| 3 | Cup semi-final | 2.0 |
| 4 | League | 1.0 |
| 5 | European tie | 1.5 |
| 6 | League, against the bottom side | 0.7 |
| 7 | League | 1.0 |

The squad has six defenders, six midfielders and three strikers, each with a made-up value in goal difference per game, from the best defender's 0.20 down to the sixth's 0.03. The team plays 4-4-2 every game.

The aim is to make the weighted total as big as possible: for every game, the value of the players who start it, times how much that game matters. The rest rule says that in any three games in a row, each player starts at most two:

$$\begin{aligned} \text{maximise } &\sum \text{weight} \times \text{starters' value} \\ \text{so that } &\text{nobody starts 3 in a row} \end{aligned}$$

<div class="plain" markdown="1">
In plain football

- **The sum** runs over all seven games.
- **The weight** says how much a game matters: the semi-final counts twice as much as a league game.
- **The starters' value** adds up the ten outfield starters' value for that game (the keeper isn't part of the rota). It's a made-up rating, not real goal difference.
- **Nobody starts 3 in a row:** in any run of three games, each player sits out at least one. Over seven games, nobody can start more than five.
</div>

## Best team every game

Pick the strongest available team for each game in turn. The stars start games 1 and 2, then the rest rule stops them, and game 3 is **the cup semi-final**. They come back for games 4 and 5, rest again for game 6, and return for game 7. The weighted total is **9.64**.

The plan isn't wrong about any single game. It's wrong because it never looks ahead.

## Planning the whole run

The optimiser treats all seven games as one problem, with a yes-or-no for every player in every game, and solves it as an integer program with `scipy`'s `milp`. Its weighted total is **10.38**, a gain of **0.74**, and its rota looks like this:

<figure class="rank-chart">
<div role="img" aria-label="A grid of fifteen players against seven games. The first-choice players, D1, D2, M1, M2 and F1, start games 1, 3, 4, 6 and 7 and rest for game 2 and the European tie. The third and fourth choices rest for the cup semi and game 6. The fringe players start games 2, 3, 5 and 6.">

</div>
<figcaption>Who starts each game under the optimiser (green). Defenders D1 to D6, midfielders M1 to M6, strikers F1 to F3, best first. The first-choice players rest for game 2 and the European tie, so they're fresh for the cup semi.</figcaption>
</figure>

The stars **rest for game 2**, so they're fresh for the semi-final, and **rest again for the European tie**.

Why not the semi *and* Europe? Because the rule won't allow it. With a game every three days, the semi and the European tie are two games apart. A star who plays both needs three rests in the seven games, such as games 2, 4 and 6, instead of two. One of the two big games has to give, and with the semi worth 2.0 and Europe 1.5, the optimiser keeps the semi.

That's the real lesson. **Picking the best team each game gets the order wrong, and no plan can have everything.** The optimiser finds the best set of compromises, and it shows exactly which ones it made.

## Checking the answer

There are far too many rotas to try them all: each game has hundreds of ways to pick four defenders, four midfielders and two strikers, seven games in a row. But the strikers alone can be checked by brute force: three ways to pick two of three, for seven games, is 3⁷ = **2,187** rotas. The best of them scores exactly what the optimiser gave the strikers, 2.85.

## How congested is real football?

Our data can't say who started, but it does have dates. Over 26 Scottish Premiership seasons, **12.9%** of a side's league games came three days or fewer after its last one. That's league games only. Cup ties and European games would push it higher for the clubs in them. Whether short rest changes results is a separate question: the [midweek myth](/myth-or-maths/midweek) found midweek games end just like weekend ones.

## Why it matters

Squads, minutes and fixtures are linked over time, so a choice now changes what's possible later. Planning one game at a time can drift into resting the stars for the biggest game. A scheduling model can't know a player's legs better than the medical staff. But given their rule and the manager's view of which games matter, it finds the best rota and shows the trade-offs it had to make. [Part 10](/learn/expected-utility-shoot-or-cross) turns to uncertainty: choosing well when you don't know what will happen, starting with shoot, cross or keep the ball.

## Limitations

- **Everything about the squad is made up,** including the players' values and the weights. Real values come from performance data and the medical staff's view.
- **One rest rule is a simplification.** Real load management tracks minutes, distance and recovery, not just starts, and substitutions change the picture.
- **Values don't change.** Real players get tired or find form during a run; here each player is worth the same in every game he starts.
- **The weights are opinions.** Change them and the plan changes, which is the point: the model makes the trade-offs visible, it doesn't decide which games matter.

## Try it yourself

Make the European tie matter more than the semi: change its weight from 1.5 to 2.5. Before running the code, guess: which games will the stars rest for now?

## Reproduce the analysis

This needs `scipy` (`pip install scipy`). The rota part needs nothing else. For the congestion count, download the Scottish Premiership files (SC0) for 2000/01 to 2025/26 from [football-data.co.uk](https://www.football-data.co.uk/scotlandm.php), saved as `SC0_0001.csv` and so on; they aren't rehosted on this site. Then:

```python
import csv
import glob
from datetime import datetime
from itertools import combinations, product

import numpy as np
from scipy.optimize import Bounds, LinearConstraint, milp

# Made up: seven games in 21 days, each with a weight for how much it matters.
games = ["League", "League", "Cup semi", "League", "Europe", "League v bottom", "League"]
weight = [1.0, 1.0, 2.0, 1.0, 1.5, 0.7, 1.0]
# Made up: a squad by position, with each player's value in goal difference per game; 4-4-2 every game.
squad = {"DEF": [("D1", 0.20), ("D2", 0.15), ("D3", 0.12), ("D4", 0.10), ("D5", 0.06), ("D6", 0.03)],
         "MID": [("M1", 0.25), ("M2", 0.18), ("M3", 0.12), ("M4", 0.08), ("M5", 0.04), ("M6", 0.02)],
         "FWD": [("F1", 0.30), ("F2", 0.15), ("F3", 0.06)]}
needed = {"DEF": 4, "MID": 4, "FWD": 2}
REST = 3   # rest rule: nobody starts all of any 3 games in a row
G = len(games)
players = [(pos, name, v) for pos, group in squad.items() for name, v in group]


def value(plan):
    """Weighted goal difference across the run: each game's weight times the value of the players who start it."""
    return sum(weight[g] * v for g in range(G) for (pos, name, v) in players if name in plan[g])


def rested_ok(starts):
    return all(sum(starts[g:g + REST]) < REST for g in range(G - REST + 1))


# 1. The best team every game, until the rest rule stops someone.
greedy, history = [], {name: [] for _, name, _ in players}
for g in range(G):
    pick = set()
    for pos, group in squad.items():
        free = [n for n, v in group if sum(history[n][-(REST - 1):]) < REST - 1]   # not started the last two
        pick |= set(free[:needed[pos]])
    for _, name, _ in players:
        history[name].append(int(name in pick))
    greedy.append(pick)

# 2. The optimiser: one yes-or-no for each player in each game, solved as an integer program.
n = len(players) * G
idx = lambda p, g: p * G + g
c = np.zeros(n)
for p, (_, _, v) in enumerate(players):
    for g in range(G):
        c[idx(p, g)] = -weight[g] * v                       # milp minimises, so flip the sign
rows, lo, hi = [], [], []
for g in range(G):                                           # each position filled in each game
    for pos in squad:
        row = np.zeros(n)
        for p, (ppos, _, _) in enumerate(players):
            row[idx(p, g)] = ppos == pos
        rows.append(row); lo.append(needed[pos]); hi.append(needed[pos])
for p in range(len(players)):                                # the rest rule, for every player and every window
    for g in range(G - REST + 1):
        row = np.zeros(n)
        row[[idx(p, k) for k in range(g, g + REST)]] = 1
        rows.append(row); lo.append(0); hi.append(REST - 1)
res = milp(c, integrality=np.ones(n), bounds=Bounds(0, 1), constraints=LinearConstraint(np.array(rows), lo, hi))
best = [{name for p, (_, name, _) in enumerate(players) if res.x[idx(p, g)] > 0.5} for g in range(G)]

no_rule = sum(weight[g] for g in range(G)) * sum(sum(v for _, v in group[:needed[pos]]) for pos, group in squad.items())
print(f"Without a rest rule (impossible): {no_rule:.2f}")
print(f"Best team every game until rested: {value(greedy):.2f}")
print(f"Optimiser: {value(best):.2f}")
print("\nWho the optimiser rests (stars = the best player in each position):")
for name in ["D1", "M1", "F1"]:
    print(f"  {name} rested for: {', '.join(games[g] for g in range(G) if name not in best[g])}")
for name in ["D1", "M1", "F1"]:
    print(f"  Greedy rests {name} for: {', '.join(games[g] for g in range(G) if name not in greedy[g])}")

# A check on the strikers alone, where every rota can be tried: 3 ways to pick 2 of 3, for 7 games.
fwd = squad["FWD"]
options = [set(c) for c in combinations([n for n, _ in fwd], 2)]
brute = max((rota for rota in product(options, repeat=G)
             if all(rested_ok([int(name in rota[g]) for g in range(G)]) for name, _ in fwd)),
            key=lambda rota: sum(weight[g] * v for g in range(G) for name, v in fwd if name in rota[g]))
fwd_value = lambda rota: sum(weight[g] * v for g in range(G) for name, v in fwd if name in rota[g])
print(f"\nStrikers only: {3 ** G:,} rotas tried, best {fwd_value(brute):.2f}; "
      f"the optimiser's strikers {fwd_value([b & {'F1', 'F2', 'F3'} for b in best]):.2f}")

# Real: how often a Premiership side's league game came three days or fewer after its last one.
# Needs the SC0 files from football-data.co.uk for 2000/01 to 2025/26 (see above).
when = lambda d: datetime.strptime(d, "%d/%m/%Y" if len(d) == 10 else "%d/%m/%y")
short = gaps = 0
for path in sorted(glob.glob("SC0_*.csv")):
    dates = {}
    with open(path, encoding="latin-1") as f:
        for r in csv.DictReader(f):
            if r.get("FTR") in ("H", "D", "A"):
                for team in (r["HomeTeam"], r["AwayTeam"]):
                    dates.setdefault(team, []).append(when(r["Date"]))
    for ds in dates.values():
        ds.sort()
        gaps += len(ds) - 1
        short += sum((b - a).days <= 3 for a, b in zip(ds, ds[1:]))
print(f"\nLeague games three days or fewer after the last, 2000/01 to 2025/26: {short:,} of {gaps:,} ({short / gaps:.1%})")
```

It prints:

```text
Without a rest rule (impossible): 13.53
Best team every game until rested: 9.64
Optimiser: 10.38

Who the optimiser rests (stars = the best player in each position):
  D1 rested for: League, Europe
  M1 rested for: League, Europe
  F1 rested for: League, Europe
  Greedy rests D1 for: Cup semi, League v bottom
  Greedy rests M1 for: Cup semi, League v bottom
  Greedy rests F1 for: Cup semi, League v bottom

Strikers only: 2,187 rotas tried, best 2.85; the optimiser's strikers 2.85

League games three days or fewer after the last, 2000/01 to 2025/26: 1,473 of 11,446 (12.9%)
```

The optimiser's rota isn't the only best one: some players are interchangeable, so a solver may return a different rota with the same total.

## Further reading

- [Scheduling (computing)](https://en.wikipedia.org/wiki/Scheduling_(computing)), Wikipedia. Scheduling problems in general, from computers to timetables.
- [Integer programming](https://en.wikipedia.org/wiki/Integer_programming), Wikipedia. The method behind the optimiser, as in Part 6.
- [scipy.optimize.milp](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.milp.html), the SciPy documentation for the solver used here.
