School Timetabling with CP-SAT
Build a working school timetabling script in Python using Google OR-Tools' CP-SAT solver. Learn constraint programming from first principles: variables, domains, hard and soft constraints, propagation, and how to decode solver output into a readable schedule.
- Estimated time
- ~30 min
- Difficulty
- advanced
- Sources
- 4 sources
A school has 12 teachers, 8 classes, 30 rooms, and 40 time slots per week. Every teacher can only be in one room at a time. Every class needs exactly the subjects on its timetable. Some teachers want mornings. Some rooms can’t hold 30 students. Write a for loop to solve this and your program will still be running at the end of term — there are more candidate schedules than atoms in the observable universe. CP-SAT solves it in seconds.
Why Brute Force Fails — and What CP-SAT Does Instead
Timetabling feels like it should be a simple matching problem. Assign each lesson to a slot and room. Done. But the number of ways to assign even a tiny school — say 4 subjects, 3 teachers, 5 slots — runs into the millions. A full secondary school explodes to numbers that can’t be written without scientific notation.
Analogy — Sudoku is like Timetabling
A Sudoku grid has 9 × 9 = 81 cells, each with 9 possible values. A brute-force solver trying all combinations would never finish in your lifetime. An experienced solver instead uses constraint propagation: when you write a “3” in one cell, every row, column, and box containing that cell can no longer contain “3”. That one placement shrinks thousands of possibilities in milliseconds. CP-SAT does the same thing — at industrial scale, with arbitrary constraints.
Constraint Programming (CP) is a paradigm built on three ideas:
- Variables — unknowns your program needs to assign (which teacher teaches which slot?)
- Domains — the set of legal values each variable could take (slots 1–40)
- Constraints — rules that eliminate combinations (teacher cannot be in two rooms at once)
The solver propagates each constraint forward through the domain of every related variable. When a variable’s domain shrinks to one value, the constraint engine propagates again. Most of the search tree is pruned before the solver even tries those branches. This is why CP-SAT can solve problems with millions of variables in seconds.
Check your understanding
What does constraint propagation actually do during search?
The Building Blocks: Variables, Domains, and Constraints
Before writing a line of code, you need to think in CP vocabulary. Here is what each concept maps to in a school timetabling context.
Mapping school scheduling to CP vocabulary
| CP concept | School timetabling meaning | CP-SAT API |
|---|---|---|
| Variable | ”Does teacher T teach subject S to class C in slot P, room R?” | IntVar or BoolVar |
| Domain | The variable is 0 (no) or 1 (yes) | NewBoolVar |
| Hard constraint | No teacher in two places at once | add(sum(...) <= 1) |
| Soft constraint | Teacher prefers mornings | Weighted penalty in minimize() |
| Objective | Minimize total preference penalty | CpModel.minimize() |
The most common CP-SAT variable you will use for scheduling is a Boolean decision variable (NewBoolVar). You create one for every possible combination of (teacher, subject, class, time slot, room). The solver assigns 0 or 1 to each. Your constraints say which subsets of these 1s are legal.
A variable whose domain is exactly 1. In CP-SAT, model.new_bool_var("name") creates one. A value of 1 means “this assignment is active”; 0 means it is not. Scheduling models use one BoolVar per (teacher, subject, class, slot, room) combination.
from ortools.sat.python import cp_model
model = cp_model.CpModel()
teachers = ["Ms. Arun", "Mr. Blake", "Dr. Chen"]
subjects = ["Math", "English", "Science"]
classes = ["Year 7", "Year 8", "Year 9"]
slots = list(range(10)) # 10 time slots
rooms = ["Room A", "Room B", "Lab 1"]
# One BoolVar per (teacher, subject, class, slot, room) combination
x = {}
for t in teachers:
for s in subjects:
for c in classes:
for p in slots:
for r in rooms:
x[t, s, c, p, r] = model.new_bool_var(
f"x_{t}_{s}_{c}_{p}_{r}"
) The total number of variables here is 3 × 3 × 3 × 10 × 3 = 810. A real school will produce tens of thousands. CP-SAT handles this comfortably.
Now watch what constraint propagation looks like when you make your first assignment — before we add any code at all.
Common misconception
You should generate candidate schedules and then filter out the bad ones.
What's actually true
This is the wrong mental model. In CP-SAT you declare what must be true (constraints) and let the solver search — you never write the candidate schedules yourself. Generating-then-filtering is brute force. The solver prunes invalid branches before generating them.
Check your understanding
In CP-SAT, what does `model.new_bool_var('x_MsArun_Math_Year7_slot2_RoomA')` represent?
Hard Constraints: The Rules That Cannot Break
Hard constraints are inviolable. The solver either finds a solution that satisfies all of them, or it reports infeasibility (no solution exists). There are four classic hard constraints in school timetabling.
Hard constraint 1 — Each lesson happens exactly once
Every (class, subject) pair must be assigned exactly one (teacher, slot, room). Too few and the class misses a lesson; too many and subjects collide.
for c in classes:
for s in subjects:
# Exactly one assignment active for this (class, subject) pair
model.add_exactly_one(
x[t, s, c, p, r]
for t in teachers
for p in slots
for r in rooms
) Hard constraint 2 — No teacher in two places at once
For every (teacher, slot) combination, at most one room assignment can be active.
for t in teachers:
for p in slots:
# At most one room per teacher per slot
model.add_at_most_one(
x[t, s, c, p, r]
for s in subjects
for c in classes
for r in rooms
) Hard constraint 3 — No class in two rooms at once
for c in classes:
for p in slots:
model.add_at_most_one(
x[t, s, c, p, r]
for t in teachers
for s in subjects
for r in rooms
) Hard constraint 4 — No room double-booked
for r in rooms:
for p in slots:
model.add_at_most_one(
x[t, s, c, p, r]
for t in teachers
for s in subjects
for c in classes
) AddExactlyOne vs. AddAtMostOne
Use add_exactly_one when the constraint must fire exactly once (each lesson must be taught). Use add_at_most_one when it can fire zero or one times (a room can be empty). In the OR-Tools Python API, add_at_most_one is more efficient than a manually summed model.add(sum(...) <= 1) because the solver knows the variable type.
Show the full OR-Tools API names for these constraints
# add_exactly_one(literals) — sum == 1
model.add_exactly_one([v1, v2, v3])
# add_at_most_one(literals) — sum <= 1
model.add_at_most_one([v1, v2, v3])
# Generic linear constraint
model.add(sum([v1, v2, v3]) <= 1)
# AllDifferent — each variable takes a distinct value (used when vars are IntVars, not BoolVars)
model.add_all_different([slot_var_1, slot_var_2, slot_var_3])
# BoolOr — at least one must be True
model.add_bool_or([v1, v2]) # v1 OR v2 == 1
# Implication
model.add_implication(v1, v2) # if v1 == 1 then v2 == 1 Check your understanding
You have two teachers: Ms. Arun and Mr. Blake. You want to ensure they are never in the same room at the same time. Which constraint expresses this?
Soft Constraints: Preferences That Cost Points
Soft constraints are preferences, not laws. A teacher would like to teach mornings. A class would prefer not to have PE last period on Friday. Breaking these constraints is allowed — but costs a penalty. The objective function minimises total penalty.
The key CP-SAT primitive for soft constraints is NewBoolVar combined with a penalty weight added to the objective.
Soft constraint — Teacher prefers morning slots (penalty if evening)
# Morning slots are 0–4; evening slots are 5–9
MORNING_SLOTS = set(range(5))
EVENING_PENALTY = 2 # cost units per evening lesson
penalties = []
for t in teachers:
for s in subjects:
for c in classes:
for p in slots:
for r in rooms:
if p not in MORNING_SLOTS and t == "Ms. Arun":
# If this assignment is active AND it's an evening slot, add penalty
penalties.append(
x[t, s, c, p, r] * EVENING_PENALTY
)
model.minimize(sum(penalties)) Soft constraint — Avoid back-to-back lessons for the same teacher (higher penalty)
BACK_TO_BACK_PENALTY = 3
for t in teachers:
for p in range(len(slots) - 1): # slot pairs (p, p+1)
is_teaching_p = sum(x[t, s, c, p, r] for s in subjects for c in classes for r in rooms)
is_teaching_p1 = sum(x[t, s, c, p+1, r] for s in subjects for c in classes for r in rooms)
# Create a BoolVar that is 1 when both adjacent slots are occupied
back_to_back = model.new_bool_var(f"b2b_{t}_{p}")
model.add(is_teaching_p >= back_to_back)
model.add(is_teaching_p1 >= back_to_back)
penalties.append(back_to_back * BACK_TO_BACK_PENALTY) The interactive below shows how toggling hard and soft constraints changes whether a solution exists — and what the penalty score looks like.
| Hard Constraint | Soft Constraint | |
|---|---|---|
| If violated | No solution exists (INFEASIBLE) | Penalty added to objective |
| CP-SAT API | model.add(...) | model.minimize(sum(penalties)) |
| Example | Teacher can't be in two rooms | Teacher prefers mornings |
| Penalty weight | N/A (infinite) | Tunable integer (e.g. 2, 5, 10) |
| Relative importance | Always satisfied first | Satisfied if hard constraints permit |
Check your understanding
The solver returns status INFEASIBLE. What is the most likely cause?
Running the Solver and Reading the Solution
Once the model is built, you create a CpSolver, call solve(), and decode the result. This is the step most tutorials rush past — reading back the values is where your BoolVars become actual schedule entries.
solver = cp_model.CpSolver()
# Optional: set a time limit (seconds) so the solver doesn't run forever
solver.parameters.max_time_in_seconds = 30.0
status = solver.solve(model)
if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
print(f"Solution status: {solver.status_name(status)}")
print(f"Objective (penalty): {solver.objective_value:.0f}")
# Decode: find every BoolVar the solver set to 1
for t in teachers:
for s in subjects:
for c in classes:
for p in slots:
for r in rooms:
if solver.value(x[t, s, c, p, r]) == 1:
print(f" Slot {p:2d} | {r:8s} | {t:12s} | {s:10s} → {c}")
elif status == cp_model.INFEASIBLE:
print("No solution exists — check for conflicting hard constraints.")
else:
print(f"Solver stopped: {solver.status_name(status)}") OPTIMAL vs FEASIBLE
OPTIMAL means the solver proved no better objective value exists. FEASIBLE means it found a valid solution but ran out of time before proving optimality. For practical timetabling, FEASIBLE is usually good enough — you get a real, constraint-satisfying schedule. Increasing max_time_in_seconds gives the solver more budget to improve.
The timetable below shows what a decoded CP-SAT output looks like for a 3-teacher, 3-class, 6-slot school. Use the filters to inspect one teacher’s or one class’s view — this mirrors exactly the read-back loop in the code above.
Check your understanding
After calling solver.solve(model), you check solver.value(x['Ms. Arun', 'Math', 'Year 7', 2, 'Room A']). It returns 1. What does this mean?
The Complete Script
Here is the full, runnable minimal timetabling script. Copy it, install OR-Tools (pip install ortools), and run it.
from ortools.sat.python import cp_model
# ── Data ─────────────────────────────────────────────────────────────────────
teachers = ["Ms. Arun", "Mr. Blake", "Dr. Chen"]
subjects = ["Math", "English", "Science"]
classes = ["Year 7", "Year 8", "Year 9"]
slots = list(range(6)) # 6 time slots per week
rooms = ["Room A", "Room B", "Lab 1"]
MORNING_SLOTS = {0, 1, 2} # Slots 0–2 are morning
# ── Model ─────────────────────────────────────────────────────────────────────
model = cp_model.CpModel()
# Decision variable: x[t, s, c, p, r] = 1 iff teacher t teaches subject s
# to class c in slot p in room r.
x = {}
for t in teachers:
for s in subjects:
for c in classes:
for p in slots:
for r in rooms:
x[t, s, c, p, r] = model.new_bool_var(f"x_{t}_{s}_{c}_{p}_{r}")
# ── Hard constraints ──────────────────────────────────────────────────────────
# H1: Each (class, subject) pair is taught exactly once per week
for c in classes:
for s in subjects:
model.add_exactly_one(
x[t, s, c, p, r]
for t in teachers for p in slots for r in rooms
)
# H2: No teacher teaches in two places at the same time
for t in teachers:
for p in slots:
model.add_at_most_one(
x[t, s, c, p, r]
for s in subjects for c in classes for r in rooms
)
# H3: No class is in two rooms at the same time
for c in classes:
for p in slots:
model.add_at_most_one(
x[t, s, c, p, r]
for t in teachers for s in subjects for r in rooms
)
# H4: No room is double-booked
for r in rooms:
for p in slots:
model.add_at_most_one(
x[t, s, c, p, r]
for t in teachers for s in subjects for c in classes
)
# H5: Each teacher can only teach subjects they are qualified for
teacher_subjects = {
"Ms. Arun": {"Math", "Science"},
"Mr. Blake": {"English", "Math"},
"Dr. Chen": {"Science", "English"},
}
for t in teachers:
for s in subjects:
if s not in teacher_subjects[t]:
for c in classes:
for p in slots:
for r in rooms:
model.add(x[t, s, c, p, r] == 0)
# ── Soft constraints (penalties) ──────────────────────────────────────────────
penalties = []
# S1: Ms. Arun prefers morning slots (penalty 2 per evening lesson)
for s in subjects:
for c in classes:
for p in slots:
if p not in MORNING_SLOTS:
for r in rooms:
penalties.append(x["Ms. Arun", s, c, p, r] * 2)
# S2: Avoid back-to-back lessons for any teacher (penalty 3)
for t in teachers:
for p in range(len(slots) - 1):
teaching_p = [x[t, s, c, p, r] for s in subjects for c in classes for r in rooms]
teaching_p1 = [x[t, s, c, p+1, r] for s in subjects for c in classes for r in rooms]
b2b = model.new_bool_var(f"b2b_{t}_{p}")
model.add(sum(teaching_p) >= b2b)
model.add(sum(teaching_p1) >= b2b)
penalties.append(b2b * 3)
model.minimize(sum(penalties))
# ── Solve ─────────────────────────────────────────────────────────────────────
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = 30.0
status = solver.solve(model)
# ── Print solution ────────────────────────────────────────────────────────────
if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
print(f"Status : {solver.status_name(status)}")
print(f"Penalty: {solver.objective_value:.0f} points\n")
header = f"{'Slot':>5} {'Room':10} {'Teacher':14} {'Subject':10} Class"
print(header)
print("-" * len(header))
for p in slots:
for r in rooms:
for t in teachers:
for s in subjects:
for c in classes:
if solver.value(x[t, s, c, p, r]) == 1:
print(f" {p:3d} {r:10} {t:14} {s:10} {c}")
elif status == cp_model.INFEASIBLE:
print("INFEASIBLE — no valid timetable exists with these constraints.")
print("Hint: check that enough slots exist for all (class, subject) pairs,")
print("and that teacher availability is not over-constrained.")
else:
print(f"Solver stopped ({solver.status_name(status)}) — try increasing max_time_in_seconds.") Install OR-Tools
pip install ortoolsTested on Python 3.9–3.12. No other dependencies required.
The script above uses a flat dict keyed by (teacher, subject, class, slot, room) tuples. For large schools (50+ teachers, 100+ slots) you may want to restructure using indexed integer keys and NumPy arrays to reduce Python dict overhead. The constraint structure stays identical — only the variable storage changes.
Check your understandingQ 1 / 5
Which CP-SAT call creates a variable that can only be 0 or 1?