Operations research · 10 min read · 2026-09-08

Medical scheduling is an operations research problem.

Building a medical team's schedule has a name in the scientific literature, it is called the nurse rostering problem or the physician scheduling problem, it has been NP-hard since a 1976 proof, and it is handled with public, free tools. This article gives you the vocabulary to search with, the three families of methods, a complete CP-SAT model that distributes on-call shifts for 12 physicians over four weeks, and above all what that model does not do.

By Félix DeBlois-Beaucage

Co-founder · development

In short

The problem has a name, and fifty years of literature

An FMG manager looking for help types "clinic scheduling software" and lands on sales pages. The same question asked in the vocabulary of operations research opens an entire library, because the problem has been studied formally since the 1970s.

For nursing staff it is the nurse rostering problem. The reference survey remains "The state of the art of nurse rostering", by Burke and colleagues in the Journal of Scheduling in 2004. For physicians the term is the physician scheduling problem, and the most useful synthesis is "State of the art in physician scheduling", published in the European Journal of Operational Research in 2018. It splits the field into three families: staffing, which decides how many people are needed, rostering proper, and re-planning, which covers what happens after publication.

That third family is the one teams underestimate most, and the literature names it separately precisely because it behaves differently from the other two.

The subject also has a Quebec history. Francis Forget submitted a master's thesis at Université de Montréal in 2002 titled "Confection automatisée des horaires de médecins dans une salle d'urgence", supervised by Jacques Ferland and Bernard Gendron, which treats the problem through integer programming. Montreal has long been one of the world centres of the discipline, through GERAD and CIRRELT, and column generation, a central method for crew rostering, was developed there in large part. The reference volume on that method is edited by Guy Desaulniers, Jacques Desrosiers and Marius M. Solomon, published by Springer in 2005.

What makes the problem hard, precisely

Even, Itai and Shamir published in 1976, in SIAM Journal on Computing, a paper titled "On the Complexity of Timetable and Multicommodity Flow Problems". Their result fits in one sentence: a very primitive version of Gotlieb's timetable problem is NP-complete, and therefore all common timetable problems are. No known algorithm guarantees the optimal solution in reasonable time across every possible instance.

The size of the search space gives the order of magnitude. Assigning a single shift per day among 12 physicians over 28 days produces 12 to the power of 28 possible assignments, roughly 1.6 million million million million million grids, before any rule is written. No enumeration will get through that, which is why a spreadsheet cannot optimize: it can check a grid, never search for one.

The figure impresses but it misleads if you stop there. A modern solver does not walk that space, it prunes it, and real FMG instances settle in fractions of a second. The practical difficulty lies elsewhere, in constraints that contradict each other. Our 27 real constraints in an FMG schedule names which ones. One constraint on its own costs nothing. Two constraints that exclude each other cost an evening.

The three families of methods

Integer linear programming writes the problem as a system of inequalities and hands it to a solver such as CBC, HiGHS or Gurobi. That is the approach of the 2002 thesis cited above, and it remains most effective when the model is naturally expressed in sums and bounds, staffing questions in particular.

Constraint programming takes the problem from the other end. You declare variables, their domains and relations between them, and the solver propagates: the moment MD-03 takes the Saturday shift, every incompatible assignment disappears from the other variables' domains. Sequence rules such as "never two consecutive shifts" or "no more than one weekend in three" fit on one line, where linear programming needs a detour.

Metaheuristics, simulated annealing, tabu search, genetic algorithms, explore the space through guided successive attempts. They earn their place when the model outgrows an exact method, and they trade away the optimality guarantee for it. For a medical team under a hundred people that guarantee is still within reach, so start with an exact method.

The most reasonable entry point today is CP-SAT, the constraint programming solver in Google's OR-Tools suite. It is free, Apache 2.0 licensed, installable with a single pip command, and it took gold in the Fixed, Free and Parallel categories of the 2025 MiniZinc Challenge, the field's reference international competition.

A complete call-rotation model, in forty lines

Here is a real call rotation reduced to its simplest form: 12 physicians, 28 days, one shift per day, never two days in a row, declared unavailability, and fairness that weighs a weekend day three times a weekday. The code runs as-is after a pip install ortools.

from ortools.sat.python import cp_model

MEDECINS = [f"MD-{i:02d}" for i in range(1, 13)]
JOURS = range(28)                                        # 4 weeks, day 0 = Monday
POIDS = [3 if j % 7 in (5, 6) else 1 for j in JOURS]     # weekend 3, weekday 1
INDISPO = {"MD-03": {5, 6, 12, 13}, "MD-07": {7, 8, 9, 10, 11}}

m = cp_model.CpModel()
g = {(md, j): m.new_bool_var(f"{md}_{j}") for md in MEDECINS for j in JOURS}

for j in JOURS:
    m.add_exactly_one(g[md, j] for md in MEDECINS)       # one shift covered per day
for md in MEDECINS:
    for j in list(JOURS)[:-1]:
        m.add(g[md, j] + g[md, j + 1] <= 1)              # never two days in a row
    for j in INDISPO.get(md, set()):
        m.add(g[md, j] == 0)                             # declared unavailability

charge = {}
for md in MEDECINS:
    charge[md] = m.new_int_var(0, sum(POIDS), f"charge_{md}")
    m.add(charge[md] == sum(POIDS[j] * g[md, j] for j in JOURS))
haut = m.new_int_var(0, sum(POIDS), "haut")
bas = m.new_int_var(0, sum(POIDS), "bas")
m.add_max_equality(haut, charge.values())
m.add_min_equality(bas, charge.values())
m.minimize(haut - bas)                                   # weighted fairness

s = cp_model.CpSolver()
s.parameters.max_time_in_seconds = 10.0
s.parameters.num_workers = 1                             # reproducible result
s.parameters.random_seed = 42
etat = s.solve(m)

if etat not in (cp_model.OPTIMAL, cp_model.FEASIBLE):    # the line people forget
    raise SystemExit(f"no schedule satisfies these rules ({s.status_name(etat)})")

print(s.status_name(etat), "load gap", s.value(haut) - s.value(bas))
for md in MEDECINS:
    jours = [j for j in JOURS if s.boolean_value(g[md, j])]
    print(md, "load", s.value(charge[md]), "shifts", len(jours), jours)

What the solver answers

With OR-Tools 9.15, this model returns the OPTIMAL status in about 0.015 seconds, with a load gap of 1. The period's total load is 44 points, 20 weekdays at 1 point plus 8 weekend days at 3 points, giving an average of 3.67 points per physician. The solver spreads those points between 3 and 4 per person, and no better split exists.

Here are the first four lines of the output, verbatim.

OPTIMAL load gap 1
MD-01 load 4 shifts 2 [4, 27]
MD-02 load 4 shifts 4 [0, 3, 18, 24]
MD-03 load 4 shifts 4 [9, 11, 15, 17]
MD-04 load 3 shifts 1 [19]

The trap inside that output

Look at the shift count column. MD-02 and MD-03 each take four, MD-04 takes one. Weighted fairness is perfect and the distribution of shift counts is absurd, because four weekday shifts add up to the same total as two shifts touching a weekend.

The solver did exactly what it was asked. The missing rule was never written, because nobody thinks to state the obvious. No physician in the group would accept four shifts while a colleague takes one, whatever points calculation you show them. Two lines fix the model, inserted before the load block.

for md in MEDECINS:
    m.add(sum(g[md, j] for j in JOURS) >= 2)
    m.add(sum(g[md, j] for j in JOURS) <= 3)

The solver then returns OPTIMAL with the same load gap of 1, but every physician carries two or three shifts. Nothing was lost, it only had to be said. That is the part of the work that takes months, and it happens away from the keyboard. It means discovering, one at a time, the rules the team applies without ever having stated them.

The same model gives five different schedules

We ran the multi-threaded version of the model five times, changing neither code nor data. It produced five different schedules, all optimal, all with a load gap of 1. The solver searches in parallel across several workers, and which optimal solution surfaces first depends on how the system schedules them.

The constraints and the objective do not single out one grid, they single out a family of equivalent grids. For a manager the consequence is concrete: she fixes one unavailability, reruns generation, and the whole schedule has moved, including the assignments she had already validated. The two lines num_workers = 1 and random_seed = 42 in the code above make the result reproducible, which our five runs confirmed by returning identical output. A production tool needs much more, it needs to hold the already-accepted assignments and reopen only the rest.

When no schedule exists

Replace the upper bound of 3 shifts with 2 and the problem becomes impossible: 12 physicians at 2 shifts each cover 24 days, not 28. The solver returns INFEASIBLE in 0.010 seconds.

It raises nothing. That behaviour is the most expensive one in a first home-built engine, because the program carries on. We removed the status check to see what happens, and the code printed a full grid where one physician carried 12 shifts including consecutive days and nine physicians carried none, in direct violation of the model's own rules. Nothing in the output announced a problem.

A wrong schedule that looks normal is worse than a visible error, and it is the same mechanism we documented when we asked ChatGPT to build an FMG schedule: the grid looks the same whether it is right or not. Check the status before reading any solution. Without that line, nothing the model produces stands up.

What separates this model from a production tool

The model above fits in forty lines and handles one kind of assignment. An FMG schedule layers at least three, the on-call shift, the walk-in block and the shared office, and those dimensions interact: a physician assigned to a block with no room available produces an imaginary schedule.

It then treats every rule as absolute. A real schedule separates hard rules, the ones never negotiated, from soft rules you would rather respect but relax when you must. That distinction is modelled with penalized slack variables in the objective, and it doubles both the model and the conversation with the team.

It cannot say why it failed. INFEASIBLE is a status, and a manager needs to know which rules contradict each other. CP-SAT can point at the rules responsible, but only if every relaxable rule was attached to an assumption declared before solving. Without that preparation, the only answer available is that no solution exists.

Finally, it ignores what happens after publication, what the literature calls re-planning. A Friday-afternoon withdrawal is not handled by rerunning full generation, it is handled by changing the minimum around the hole, telling the right people, and keeping the record of who accepted what. That is where most management hours live, and the model above covers none of it.

Then come the parts that are not optimization at all: an interface a physician opens three minutes a month on a phone, authentication, logs, backups, and the obligations that follow personal information such as absences and availability. Our Synchro and an in-house tool comparison prices those line items, including the cases where building is still right.

Where to start if you are building

  1. Write your rules before your code. Number them, and note for each whether it is absolute or negotiable. It is the most useful deliverable of the project, and it serves you even if you end up buying.
  2. Rerun this article's model on your real data. A period you have already published, whose right answer you know, will tell you in one evening whether your formulation holds.
  3. Look for what the solver is allowed to do that you would not accept. That is how you find the unwritten rules, like the shift count above.
  4. Make the result reproducible from day one, then carry already-accepted assignments from one generation to the next.
  5. Treat re-planning as a separate project, because it weighs more than initial generation and does not yield to the same tool.

Where Synchro fits

Synchro uses OR-Tools CP-SAT for office and activity assignment, on the same principle as the model published here, with the three dimensions layered and the distinction between hard and soft rules. Each physician declares their constraints in plain language, the call list is weighted rather than counted, and a released shift is picked up self-serve instead of through a phone chain. Pricing is published, $10 CAD per user per month billed annually.

What Synchro does not do, as of today. The platform holds no patient data and does not replace your EMR. It does not produce your AMP list and does not calculate your attendance rate. If your need is limited to a call rotation in a small stable team, a well-kept spreadsheet will take you further than a subscription.

Further reading

Frequently asked questions

What is medical scheduling called in operations research?

It has two names depending on who is being scheduled. The nurse rostering problem for nursing staff, and the physician scheduling problem for doctors. The reference survey for the first is the one by Burke and colleagues, published in the Journal of Scheduling in 2004. For the second, the synthesis by Erhard and colleagues in the European Journal of Operational Research, in 2018, splits the field into three families: staffing, rostering and re-planning. Searching under those terms rather than "scheduling software" gets you fifty years of literature instead of sales pages.

Is the scheduling problem really NP-hard?

Yes, and the result is old. Even, Itai and Shamir proved in 1976, in "On the Complexity of Timetable and Multicommodity Flow Problems", that a very primitive version of the timetable problem is NP-complete, and therefore that all common timetable problems are. In practice that means no known algorithm guarantees an optimal solution in reasonable time across every instance. It does not mean an FMG schedule is out of reach: a modern solver settles a 12-physician call rotation over four weeks in hundredths of a second. The theoretical difficulty describes the worst case, not your clinic.

Which method should I use to build a scheduling engine?

Three families share the ground. Integer linear programming, with a solver such as CBC, HiGHS or Gurobi, which shines when the model is naturally written as linear inequalities. Constraint programming, of which OR-Tools CP-SAT is now the most accessible representative, which expresses sequence rules like "never two consecutive shifts" directly. Metaheuristics (simulated annealing, tabu search, genetic algorithms), useful when the model grows too large for an exact method, at the cost of the optimality guarantee. For a team under a hundred people, start with CP-SAT: it is free, Apache 2.0 licensed, and it took gold in the Fixed, Free and Parallel categories of the 2025 MiniZinc Challenge.

How many lines of code for a 12-physician call rotation?

About forty, in Python with OR-Tools, and this article publishes the full code. The model covers one shift per day over 28 days, a ban on consecutive shifts, declared unavailability, and weighted fairness counting a weekend day as three times a weekday. With OR-Tools 9.15, it returns an optimal schedule in roughly 0.015 seconds. That code is not where the months go. They go into everything it does not do: explaining why no solution exists, handling a withdrawal after publication, telling a negotiable rule from an absolute one.

Why does a solver return a different schedule on every run?

Because it searches in parallel across several worker threads and the order results arrive in varies. We ran this article's model five times without changing anything: five different schedules, all optimal, all with the same load gap of 1. The constraints and the objective do not determine a single grid, they determine a set of equivalent grids. To get a reproducible result, set num_workers to 1 and random_seed to a fixed value, which produced five identical runs in our test. Without it, a manager who reruns generation after a small correction sees the whole schedule move, and stops trusting the tool.

What does a solver do when no schedule satisfies the rules?

It returns the INFEASIBLE status, and it raises nothing. If your code reads the variables anyway, it gets meaningless values and prints them as though they were a schedule. In our test, the version without a status check printed one physician with 12 shifts including consecutive days and nine physicians with none at all, in direct violation of the model's own rules. This is the most expensive bug in a first home-built engine, because it produces a normal-looking grid. Always check the status before reading a solution, and decide in advance what to show when there is none.

Public sources cited: S. Even, A. Itai and A. Shamir, "On the Complexity of Timetable and Multicommodity Flow Problems", SIAM Journal on Computing 5(4), 1976, pp. 691-703; E. K. Burke, P. De Causmaecker, G. Vanden Berghe and H. Van Landeghem, "The state of the art of nurse rostering", Journal of Scheduling 7(6), 2004, pp. 441-499; M. Erhard, J. Schoenfelder, A. Fügener and J. O. Brunner, "State of the art in physician scheduling", European Journal of Operational Research 265(1), 2018, pp. 1-18; F. Forget, "Confection automatisée des horaires de médecins dans une salle d'urgence", master's thesis, Université de Montréal, 2002, supervised by J. Ferland and B. Gendron; G. Desaulniers, J. Desrosiers and M. M. Solomon (eds.), Column Generation, Springer, 2005; 2025 MiniZinc Challenge results for CP-SAT's gold medals; MSSS funding and professional support program for FMGs, in force from 2026-04-01, for the constraint count. The execution times, the load gap, the run-to-run variability and the infeasibility behaviour come from our own tests with OR-Tools 9.15 under Python, reported as such and not as published results. Product names belong to their respective owners and this site is not affiliated with any of them.

About the author

Félix DeBlois-Beaucage

Co-founder · development

Co-founder of Synchro. He builds the product and is the person responsible for privacy.

All his articles[email protected]

You have the model. What follows is longer than the model.

A demo on your real call shifts, offices and walk-in blocks, no commitment.

Book a demo