Skip to content
← All insights

Rosters are a constraint problem, not a calendar

10 min readOptima

Scheduling software usually gets specified as a calendar. Drag people onto shifts, see who is where, print it.

That describes the output. It does not describe the problem, and building for it produces a system that is a slightly faster spreadsheet: still relying on a coordinator to hold the constraints in their head, still failing in the same place, just with nicer rendering.

The problem underneath is constraint satisfaction, and it behaves in ways a calendar does not prepare you for.

Why filling shifts in order fails

The obvious implementation walks the shifts and assigns whoever is available. It is easy to write, easy to explain, and it strands exactly the shifts you care most about.

Sequential allocation compared with whole-period allocationFive shifts are filled from three staff. Filling them in order assigns the only qualified specialist to an early general shift, so the later specialist shift cannot be filled at all and is left uncovered. Considering the whole period first reserves that person for the shift only they can cover, and every shift is filled.FILLED IN ORDEREach shift takes whoever is available now.Mon generalAna (specialist)Tue generalBorisWed generalChrisThu generalBorisFri SPECIALISTnobody left, uncoveredWHOLE PERIOD CONSIDERED FIRSTThe constrained shift is identified before anything is assigned.Mon generalBorisTue generalChrisWed generalBorisThu generalChrisFri SPECIALISTAna, reservedSame five shifts, same three people. The only difference is the order decisions are made in.
Greedy allocation is valid at every step and impossible at the end, because the early assignments spend the availability the later ones needed. Nothing about the roster changed here except when the constrained shift was looked at.

Every individual assignment above is valid at the moment it is made. The schedule is legal at every step and impossible at the end, because the early decisions spent the availability the constrained shift needed.

This is the most important property to understand: a roster cannot be validated one shift at a time. Local correctness does not compose into global feasibility.

Hard and soft are genuinely different

Not all rules are the same kind of rule, and treating them as one list is what produces systems that either generate illegal rosters or refuse to generate anything.

Hard constraints cannot be violated. A statutory rest period, a required qualification, a contracted maximum, a current certification. A roster that breaches one is not a worse roster, it is not a roster. These filter the search space.

Soft constraints should be optimised but can bend. Preferred locations, shift-pattern continuity, keeping teams together, fair distribution of nights and weekends. These are terms in an objective function, each with a weight.

// Hard: filters. A candidate either survives these or is not a candidate.
bool IsEligible(Person p, Shift s) =>
    p.Qualifications.IsSupersetOf(s.RequiredQualifications)
    && p.IsAvailable(s.Window)
    && RestPeriodSatisfied(p, s)
    && ContractedHours(p, s.Period) + s.Hours <= p.ContractedMax;

// Soft: score. Higher is better, and the weights are a business decision.
double Score(Person p, Shift s) =>
      W.Continuity * PatternContinuity(p, s)
    + W.Proximity  * (1 - NormalisedTravel(p, s))
    + W.Fairness   * UnsociableHoursDeficit(p)
    + W.Preference * p.PreferenceFor(s.Location);

Keeping these in separate code paths matters more than it looks. The moment a hard constraint is expressed as a very large weight, the solver will happily violate it when the arithmetic works out, and you will find out during an audit.

Fairness only exists if you measure it

Distributing nights, weekends and holidays evenly is not a nicety. In healthcare staffing it is a retention mechanism, and retention is usually the largest cost in the whole system.

The mistake is scoring fairness within the current period. Somebody who worked three of the last four weekends looks identical, this period, to somebody who worked none. Fairness needs a running position:

SELECT person_id,
       SUM(CASE WHEN is_unsociable = 1 THEN hours ELSE 0 END) AS unsociable_hours,
       SUM(hours)                                             AS total_hours
  FROM shift_assignments
 WHERE occurred_at >= DATEADD(month, -6, SYSUTCDATETIME())
 GROUP BY person_id;

That rolling figure feeds the next allocation as an input. Without it the system is fair in a way nobody experiences as fair, because people experience their own history rather than a single fortnight.

Solve the constrained parts first

You do not need a general-purpose solver to beat sequential allocation, and for most rosters you should not start with one.

The heuristic that gets you most of the way is ordering by how constrained each shift is. Count the eligible people per shift, fill the shifts with fewest candidates first, and only then fill the ones almost anybody can cover. That is the most-constrained-variable heuristic, and it directly prevents the failure in the diagram above.

Add bounded backtracking for what it still cannot close: when a shift has no eligible candidate left, undo the most recent assignment that removed one and try the next best. Good ordering plus a little backtracking handles the overwhelming majority of real rosters.

Reach for a proper constraint solver when the objective genuinely needs optimising rather than satisfying, when constraints interact across many shifts at once, or when problem size makes heuristics unreliable. It is a real step up in capability and a real step up in how hard the result is to explain, which is the part people underestimate.

The system has to explain itself

Somebody will ask why a particular shift went to a particular person. In staffing somebody always asks, and often the person asking is unhappy.

“The optimiser decided” does not survive that conversation. Every assignment should carry the reasoning that produced it:

{
  "shift": "2026-03-14-night-ward-3",
  "assigned": "person-4471",
  "eligible_count": 3,
  "chosen_because": ["highest unsociable-hours deficit", "pattern continuity with 13th"],
  "not_chosen": [
    { "person": "person-2210", "reason": "rest period would be breached" },
    { "person": "person-8890", "reason": "lower fairness deficit, would worsen balance" }
  ]
}

This is worth the storage. It turns disputes into a record, it makes the weights debuggable when rosters start feeling wrong, and it is the difference between coordinators trusting the system and quietly overriding it every week. A scheduling system people override has failed, however good its allocation is.

Re-allocation is the normal case

The roster starts changing the moment it is published. Sickness, cancellations, urgent cover. Treating that as an exception path is a design error, because it is most of the system’s working life.

The requirement is narrower than full re-solving: absorb the change without disturbing assignments people have already planned their lives around. Confirmed shifts are pinned, the affected window is re-solved, and the blast radius is bounded deliberately rather than emerging from whatever the algorithm happens to do.

Rebuilding the whole period on every change produces mathematically better rosters and is operationally unusable, because a Tuesday sickness call should not move somebody’s Saturday.

What to get right first

If you are specifying one of these, the order that matters is: model the hard constraints precisely, because compliance depends on it. Keep a running fairness position, because retention depends on it. Order allocation by constraint, because coverage depends on it. Record why every decision was made, because adoption depends on it.

The calendar view is the easy part, and it is the part everybody specifies first.

Working on something similar?

Tell us what you are building. We will come back with an honest view of scope, approach and timeline.

We reply within one business day.