Articles / Operations Research
Workforce Planning Optimization Using Python
Plan a repeating workweek with pyworkforce 0.5.4. Verify a nine-person coverage plan and compare minimum headcount with minimum absolute staffing gap.

How many people do you need when demand changes through the week and each person works five consecutive days? A daily peak alone does not account for days off. This example models seven weekly work patterns, solves for their headcounts and checks the coverage they produce.
The input is synthetic daily staffing demand, not a forecast or a measured call-center result. pyworkforce supplies two useful objectives: meet every requirement with the fewest people, or minimize the total absolute coverage gap.
The repeating-week assumption
| Day | Required people |
|---|---|
| Monday | 5 |
| Tuesday | 8 |
| Wednesday | 9 |
| Thursday | 7 |
| Friday | 6 |
| Saturday | 4 |
| Sunday | 3 |
Each pattern covers five consecutive days and leaves two days off. There are seven rotations, one per starting day. This is a repeating seven-day cycle: a Thursday-start pattern also covers Monday across the week boundary. It is not a one-off week in which someone starts Thursday and somehow works the preceding Monday.
We encode that cycle as one row with seven periods. The scheduling API calls its row count num_days, but here num_days=1 means one weekly cycle and periods=7 means its seven days. The library does not automatically connect a changing workforce between separate rows or weeks.
The minimum-headcount model
Let x_p be the nonnegative integer number of people assigned to pattern p, and c_{p,i} indicate whether it covers day i. With equal unit costs, the objective is:
For each day, coverage must meet demand:
MinRequiredResources builds this model with OR-Tools CP-SAT. Its default pattern cost is 1, so cost equals headcount in this example. A custom cost_dict changes that objective to a weighted sum; it then need not equal the number of people.
Solve the weekly pattern counts
Use Python 3.12–3.14 and PyWorkforce 0.5.4:
python -m pip install pyworkforce==0.5.4
from pyworkforce.scheduling import MinRequiredResources
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
required = [5, 8, 9, 7, 6, 4, 3]
shifts_coverage = {
"Start_Mon": [1, 1, 1, 1, 1, 0, 0],
"Start_Tue": [0, 1, 1, 1, 1, 1, 0],
"Start_Wed": [0, 0, 1, 1, 1, 1, 1],
"Start_Thu": [1, 0, 0, 1, 1, 1, 1],
"Start_Fri": [1, 1, 0, 0, 1, 1, 1],
"Start_Sat": [1, 1, 1, 0, 0, 1, 1],
"Start_Sun": [1, 1, 1, 1, 0, 0, 1],
}
scheduler = MinRequiredResources(
num_days=1,
periods=7,
shifts_coverage=shifts_coverage,
required_resources=[required],
max_period_concurrency=sum(required),
max_shift_concurrency=max(required),
max_search_time=5.0,
)
solution = scheduler.solve()
if solution["status"] not in {"OPTIMAL", "FEASIBLE"}:
raise RuntimeError(f"No usable coverage plan: {solution['status']}")
max_period_concurrency caps coverage in any period; max_shift_concurrency caps the count in any one pattern. The chosen caps, 42 and 9, admit the nine-person certificate below. Smaller caps can restrict or invalidate a coverage plan.
total = sum(item["resources"] for item in solution["resources_shifts"])
print(f"Status: {solution['status']}")
print(f"Total workers: {total}")
print("Workers per pattern:")
for item in solution["resources_shifts"]:
print(f" {item['shift']}: {item['resources']}")
One optimal certificate, independent of which allocation a solver run returns, is:
Total workers: 9
Workers per pattern:
Start_Mon: 4
Start_Tue: 1
Start_Wed: 1
Start_Thu: 0
Start_Fri: 0
Start_Sat: 2
Start_Sun: 1
It covers [7, 8, 9, 7, 6, 4, 4] people from Monday to Sunday. Every entry meets demand. Nine is also a lower bound: Wednesday requires nine people, and the 42 required person-days divided by five workdays per person gives ceil(42 / 5) = 9. The certificate attains that bound, proving the model's minimum headcount. There can be other optimal allocations.
Check the returned coverage
Validate the actual returned allocation rather than assuming it matches that certificate:
assigned = {item["shift"]: item["resources"] for item in solution["resources_shifts"]}
for i, day in enumerate(days):
covered = sum(assigned[shift] * mask[i] for shift, mask in shifts_coverage.items())
if covered < required[i]:
raise ValueError(f"Coverage failed for {day}: {covered} < {required[i]}")
print(f"{day}: covered={covered}, required={required[i]}")
The CP-SAT statuses matter: OPTIMAL includes an optimality proof; FEASIBLE gives a valid plan without that proof. INFEASIBLE, UNKNOWN and MODEL_INVALID do not supply a usable plan. In 0.5.4 those results include -1 counts and an Unknown shift, so check the status before summing headcount or indexing coverage arrays. A time limit is not a guarantee that a solution will be found.
Compare the closest-fit objective
MinAbsDifference minimizes the sum of absolute period gaps. It does not guarantee demand coverage or minimize headcount. Keep its result separate and report shortages as well as its objective:
from pyworkforce.scheduling import MinAbsDifference
fit_scheduler = MinAbsDifference(
num_days=1,
periods=7,
shifts_coverage=shifts_coverage,
required_resources=[required],
max_period_concurrency=sum(required),
max_shift_concurrency=max(required),
max_search_time=5.0,
)
fit_solution = fit_scheduler.solve()
if fit_solution["status"] not in {"OPTIMAL", "FEASIBLE"}:
raise RuntimeError(f"No usable closest-fit plan: {fit_solution['status']}")
fit_assigned = {item["shift"]: item["resources"] for item in fit_solution["resources_shifts"]}
print(f"Status: {fit_solution['status']}")
print(f"Total workers: {sum(fit_assigned.values())}")
print(f"Absolute gap: {fit_solution['cost']}")
for i, day in enumerate(days):
covered = sum(fit_assigned[shift] * mask[i] for shift, mask in shifts_coverage.items())
print(f"{day}: covered={covered}, shortage={max(0, required[i] - covered)}")
A closest-fit certificate uses counts [3, 3, 0, 0, 0, 1, 1] in the same pattern order. Its eight people cover [5, 8, 8, 7, 6, 4, 2]: one person short on Wednesday and one on Sunday, for a total absolute gap of 2. That is optimal for this objective because total coverage is a multiple of five while total demand is 42; an absolute gap below two is impossible. A small gap therefore does not make this an acceptable plan when every day's requirement is mandatory.
Version limits and the next planning step
In published 0.5.4, both scheduling solvers ignore the requested num_search_workers because it is assigned outside CP-SAT's parameters object. MinAbsDifference also bounds its gap variables by max_period_concurrency: for example, demand 9 with capacity 1 cannot represent the valid gap 8 and can return INFEASIBLE. The generous capacity in this example avoids that defect. Raising a genuine operational capacity just to obtain a result would change the model.
These defects in the 0.5.4 source have local source corrections prepared; they are not in the pinned package. Verify a release containing them before relying on those corrected behaviors. No specific parallelism or runtime is promised here.
For a real operation, demand comes from a forecast or a staffing calculation such as Erlang C using supplied call volume, handling time and service targets. Scheduling converts those staffing requirements into pattern counts; it does not forecast incoming demand. The next step is assigning named people. Eligibility, availability, rest between shifts and changing week boundaries need explicit rules beyond this aggregate repeating-week example.
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.