Articles / Operations Research
How to Solve Scheduling Problems in Python
Build an employee roster with pyworkforce 0.5.4: coverage, availability, minimum hours, solver statuses and the limits of its rest and preference rules.

You need two people on the morning shift, two in the afternoon and one at night. Knowing that total does not tell you who works each shift. Rostering assigns named people to days and shifts; workforce planning determines staffing requirements first.
This example uses pyworkforce to build a seven-day roster for five people. It checks the solver status before reading assignments and separates the model's constraints from rules it cannot enforce.
The model behind MinHoursRoster
MinHoursRoster builds a binary model and solves it with OR-Tools CP-SAT. A variable is 1 when a person works a particular shift on a particular day, and 0 otherwise. The model requires:
- At least the requested coverage for every day and shift.
- At most one shift per person per day.
- At least
min_working_hoursfor each person over the whole horizon. - At most
max_restingdays off per person over that horizon. - No assignment to a person's explicitly banned day/shift.
Without preferences, the objective minimizes total scheduled hours. With preferences, it subtracts a reward for preferred assignments, weighted by resources_prioritization (default weight 1). The returned cost is that objective value; it is not a payroll amount.
A runnable seven-day roster
Use Python 3.12–3.14 and the published PyWorkforce 0.5.4 package. The example leaves out directed shift transitions because of the version-specific defect explained below.
python -m pip install pyworkforce==0.5.4
Days are numbered 0–6. All shifts last eight hours; coverage is lower on days 5 and 6. Alice cannot work the night shift on day 5.
from pyworkforce.rostering.binary_programming import MinHoursRoster
resources = ["Alice", "Bob", "Carlos", "Diana", "Eve"]
solver = MinHoursRoster(
num_days=7,
resources=resources,
shifts=["Morning", "Afternoon", "Night"],
shifts_hours=[8, 8, 8],
min_working_hours=40,
max_resting=2,
banned_shifts=[
{"resource": "Alice", "shift": "Night", "day": 5},
],
required_resources={
"Morning": [2, 2, 2, 2, 2, 1, 1],
"Afternoon": [2, 2, 2, 2, 2, 1, 1],
"Night": [1, 1, 1, 1, 1, 1, 1],
},
max_search_time=5.0,
)
result = solver.solve()
status = result["status"]
if status not in {"OPTIMAL", "FEASIBLE"}:
raise RuntimeError(f"No usable roster: {status}")
print(f"Status: {status}")
print(f"Scheduled hours: {result['shifted_hours']}")
print(f"Objective: {result['cost']}")
print(f"Shifts assigned: {result['total_shifts']}")
Coverage requires 31 assignments: 25 on days 0–4 and six on days 5–6. At eight hours each, the lower bound is 248 hours. This model can attain it, so an OPTIMAL result has 31 assignments and objective 248. A merely FEASIBLE result need not attain that bound. The particular person-to-shift assignment can vary between runs.
Read assignments only after checking status
Run this block after the example above. Worked shifts are in resource_shifts; days off are in the separate resting_resource list. A worked assignment does not use a string "False" to mark rest.
for assignment in result["resource_shifts"]:
print(
f"Day {assignment['day']} | "
f"{assignment['resource']:<8} -> {assignment['shift']}"
)
print("\nDays off:")
for rest in result["resting_resource"]:
print(f" {rest['resource']}: day {rest['day']}")
The CP-SAT status definitions distinguish:
- OPTIMAL: a feasible solution with optimality proved.
- FEASIBLE: a solution exists, but optimality was not proved.
- INFEASIBLE: the model was proved impossible.
- UNKNOWN: no solution or infeasibility proof was obtained before stopping.
- MODEL_INVALID: the submitted model failed validation.
For a non-solution status, PyWorkforce 0.5.4 returns placeholder entries such as resource -1 and shift "Unknown". Those entries are not a schedule. For example, requesting six morning workers when only five people exist is impossible; inspect capacity and availability rather than increasing the search time.
Days off, rest between shifts and a 0.5.4 limitation
max_resting=2 limits days off to two; it does not guarantee two days off. min_working_hours=40 is a floor across seven days. Neither parameter enforces a maximum weekly workload, consecutive-days limit or minimum number of hours between shifts. Shift labels and durations alone do not provide start/end timestamps.
The non_sequential_shifts API is intended to prohibit a directed pair on consecutive days, such as Night followed by Morning. In published 0.5.4, its constraint also blocks other next-day assignments after the origin; multiple prohibited destinations can block the origin entirely. The example therefore does not claim to enforce that rule. A source correction has been prepared, but is not included in the pinned PyPI package. Use a verified release containing the correction before relying on this feature.
The same version assigns num_search_workers outside CP-SAT's parameters object, so the requested worker count is not applied to the engine. The example does not promise a particular level of parallelism. These limitations were reproduced against the 0.5.4 implementation.
Availability, preferences and skills
A ban affects one person, shift and day. To make someone unavailable for an entire day, ban every shift that day. A preference is a soft objective reward, not a guaranteed assignment:
# Optional inputs for a separate model using the same resources and shifts.
banned_shifts = [
{"resource": "Diana", "shift": shift, "day": 0}
for shift in ["Morning", "Afternoon", "Night"]
]
resources_preferences = [
{"resource": "Bob", "shift": "Morning"},
]
With a preference enabled, report shifted_hours separately from cost: the objective includes the reward. Removing Diana from day 0 also leaves only four available people against five required positions, so that availability change would make this particular example infeasible.
pyworkforce.staffing.MultiSkillStaffing optimizes counts of skill profiles. It does not automatically restrict named people in MinHoursRoster to qualified roles. A roster needs explicit eligibility restrictions for its own resources and shifts.
For a larger horizon, record the status and time limit, validate the returned assignments against your operational rules, and carry boundary assignments into any rolling-horizon model. Solving weeks independently can lose a rule spanning Sunday and Monday. The model provides a starting point for an operational roster; additional rest, fairness, qualification and workload constraints need their own representation and checks.
Are you building an AI or data platform?
If you need to adapt this approach to your system, tell me about your data and the technical decision you need to resolve.