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:

  1. Variables — unknowns your program needs to assign (which teacher teaches which slot?)
  2. Domains — the set of legal values each variable could take (slots 1–40)
  3. 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.

Drag the sliders. Watch the red bar (brute force) rocket to astronomical numbers while the green bar (CP-SAT) stays tractable.

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 conceptSchool timetabling meaningCP-SAT API
Variable”Does teacher T teach subject S to class C in slot P, room R?”IntVar or BoolVar
DomainThe variable is 0 (no) or 1 (yes)NewBoolVar
Hard constraintNo teacher in two places at onceadd(sum(...) <= 1)
Soft constraintTeacher prefers morningsWeighted penalty in minimize()
ObjectiveMinimize total preference penaltyCpModel.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.

Boolean decision variable (BoolVar) def.

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.

timetable.py Setting up CP-SAT and creating your first variables
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.

Click an available cell to assign that teacher. Watch the AllDifferent constraint immediately eliminate that slot for every other teacher — that propagation is what prunes the search before the solver ever guesses.

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.

Uncheck a hard constraint and the schedule becomes INFEASIBLE — the solver returns nothing. Uncheck a soft one and watch only the penalty score move. CP-SAT searches for feasibility first, then minimizes total penalty.
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 roomsTeacher prefers mornings
Penalty weight N/A (infinite)Tunable integer (e.g. 2, 5, 10)
Relative importance Always satisfied firstSatisfied if hard constraints permit
Key differences between hard and soft constraints in CP-SAT

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.

timetable.py Solving and decoding the solution
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.

Filter by teacher to verify the AllDifferent constraint: no teacher appears twice in one slot. Filter by class to verify each subject appears exactly once.

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.

school_timetable.py Complete school timetable script
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 ortools

Tested 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?