Workforce Planning Optimization Using Python
Learn how to optimize workforce staffing in Python with pyworkforce. Compute the minimum headcount that meets demand using constraint optimization on top of Google OR-Tools.
Human resource management faces a central and recurring challenge: how many workers do you need at any given time to meet demand without incurring unnecessary costs? Overstaffing generates fixed expenses that aren't justified during low-demand periods; understaffing creates bottlenecks, costly overtime, and dissatisfied customers. Constraint optimization provides a rigorous mathematical framework for solving exactly this dilemma.
In this article, we'll walk through how to formulate and solve a workforce planning problem in Python with pyworkforce, an open-source library that wraps the underlying optimization model behind a clean, scikit-learn-style API — built on top of Google OR-Tools.
The Workforce Planning Problem
Imagine you manage a call center that operates seven days a week. Based on historical data, you know how many agents you need each day:
| Day | Required Agents |
|---|---|
| Monday | 5 |
| Tuesday | 8 |
| Wednesday | 9 |
| Thursday | 7 |
| Friday | 6 |
| Saturday | 4 |
| Sunday | 3 |
Each agent works five consecutive days and rests two. The cost of having an agent available is fixed, regardless of demand peaks or valleys. The question is: how many agents should follow each weekly work pattern to meet demand at the lowest total headcount?
This type of problem appears in hospitals (nurses per shift), retail stores (cashiers per hour), airlines (crew per flight), and virtually any service industry with variable demand.
Framing It as a Coverage Problem
Behind the scenes this is a classic set-covering optimization. The key is identifying three components:
Decision Variables
A weekly work pattern is a fixed sequence of working and resting days — for example, "work Monday through Friday, rest the weekend." With five consecutive working days there are seven such patterns (one for each possible start day). We define x_p as the number of workers assigned to pattern p.
Objective Function
We want to minimize total cost. If all workers have the same salary, this is equivalent to minimizing the total number of workers hired:
Constraints
For each day of the week, the sum of all workers whose pattern covers that day must be greater than or equal to the required demand. If c_{p,i} = 1 when pattern p is working on day i:
Additionally, all variables must be non-negative integers, since we can't hire fractions of people:
You could hand-code this model with a low-level solver interface, declaring every variable and constraint by hand. But that's exactly the boilerplate pyworkforce removes.
pyworkforce: A High-Level Optimization API
pyworkforce is a Python library of standard tools for workforce management — queuing, scheduling, rostering, and break optimization. Instead of writing the mathematical model yourself, you describe the problem in domain terms (shifts, coverage, demand) and the library builds and solves the underlying constraint program with Google OR-Tools. I maintain the library; you can find the full feature overview, documentation, and source on the pyworkforce project page.
pip install pyworkforce
The scheduling module exposes two solvers that answer "how many resources per shift?":
MinRequiredResources: minimizes the cost of the assigned resources while guaranteeing demand is fully covered in every period.MinAbsDifference: minimizes the absolute difference between required and scheduled resources — useful when slight over- or under-coverage is acceptable and you want the closest fit rather than guaranteed coverage.
Modeling the Problem with pyworkforce
We map the days-off problem directly: the seven days of the week become the periods, and each weekly work pattern becomes a shift whose coverage vector marks the days it works (1) and rests (0).
from pyworkforce.scheduling import MinRequiredResources
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
required = [5, 8, 9, 7, 6, 4, 3] # required workers per day
# Each shift is a weekly work pattern: 5 consecutive working days (1), 2 off (0)
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, # one weekly cycle
periods=7, # one period per day of the week
shifts_coverage=shifts_coverage,
required_resources=[required], # demand per period, one row per day-cycle
max_period_concurrency=sum(required),
max_shift_concurrency=max(required),
)
solution = scheduler.solve()
The max_period_concurrency and max_shift_concurrency parameters cap how many resources can be active in any single period and assigned to any single shift; setting them generously (the totals above) leaves the solver free to find the true optimum.
Solving and Interpreting Results
The solver returns a dictionary with the optimization status, the objective cost, and the number of resources assigned to each shift per day:
total = sum(item["resources"] for item in solution["resources_shifts"])
print(f"Status: {solution['status']}")
print(f"Total workers: {total}")
print()
print("Workers per pattern:")
for item in solution["resources_shifts"]:
print(f" {item['shift']}: {item['resources']}")
Output looks similar to:
Status: OPTIMAL
Total workers: 11
Workers per pattern:
Start_Mon: 3
Start_Tue: 1
Start_Wed: 3
Start_Thu: 1
Start_Fri: 0
Start_Sat: 2
Start_Sun: 1
The solver found that you need 11 workers in total — the same optimum a hand-written model would reach, with none of the manual variable and constraint bookkeeping. Each worker simply follows the start pattern they were assigned.
You can verify full coverage programmatically:
# Resources assigned to each pattern
assigned = {item["shift"]: item["resources"] for item in solution["resources_shifts"]}
print("\nCoverage verification:")
for i, day in enumerate(days):
covered = sum(assigned[s] for s in shifts_coverage if shifts_coverage[s][i] == 1)
status_str = "OK" if covered >= required[i] else "FAIL"
print(f" {day}: covered={covered}, required={required[i]} [{status_str}]")
Closest-Fit Scheduling with MinAbsDifference
Guaranteed coverage is not always the right objective. When demand is noisy or slight under-coverage is tolerable, you often want the schedule that sits closest to demand rather than one that always meets or exceeds it. MinAbsDifference swaps the objective for exactly that — minimizing the total absolute gap between required and scheduled resources — while keeping the same interface:
from pyworkforce.scheduling import MinAbsDifference
scheduler = MinAbsDifference(
num_days=1,
periods=7,
shifts_coverage=shifts_coverage,
required_resources=[required],
max_period_concurrency=sum(required),
max_shift_concurrency=max(required),
)
solution = scheduler.solve()
print(f"Status: {solution['status']}, Cost: {solution['cost']}")
Swapping one class for another lets you compare a coverage-guaranteed plan against a balanced plan without touching the rest of your code.
Beyond Headcount: The Full Planning Pipeline
Computing how many workers you need is only the first stage. pyworkforce composes the entire workforce-management pipeline:
- Queuing (
pyworkforce.queuing) — before scheduling, estimate the demand itself. TheErlangCmodel converts call volume, average handling time, and a target service level into the number of positions required per interval, accounting for shrinkage (non-productive agent time). - Scheduling (
pyworkforce.scheduling) — theMinRequiredResources/MinAbsDifferencesolvers we used above, turning per-period demand into per-shift headcount. - Rostering (
pyworkforce.rostering) — once you know how many people each shift needs, assign specific individuals to shifts while honoring rest rules, banned shifts, and preferences. This is the subject of the next article.
A realistic plan chains them: ErlangC produces the required_resources, the scheduler turns that into shift headcount, and the roster assigns named people to each shift.
Validating the Model
Before trusting the results of any optimization model, it's good practice to perform basic validation:
- Check feasibility: confirm
solution["status"]isOPTIMAL(orFEASIBLE) and that every period's coverage meets demand. - Lower bound: the maximum daily demand (
max(required) = 9) is a lower bound on the number of workers. If the solver returns fewer, something is wrong. - Edge cases: test with constant demand (the same value every day) — for five-on, two-off patterns the result should be exactly
ceil(demand / 5 * 7)workers. - Sensitivity: slightly modify the demand and verify the solution changes coherently.
Conclusion
Constraint optimization transforms a seemingly complex resource management problem into a mathematical model that can be solved in milliseconds for typical cases. pyworkforce democratizes access to these techniques in Python: instead of hand-coding variables and constraints, you describe the problem in workforce terms — shifts, coverage, demand — and the library builds and solves the OR-Tools model for you.
The key is still correctly identifying the decision variables (which work patterns exist?), the objective function (minimize headcount or minimize the gap?), and the constraints (how much coverage does each period need?). Once you have that conceptual clarity, pyworkforce handles the rest.
The natural next step is scheduling: given a number of workers, how do you assign each specific person to each shift? That is the rostering problem, and the topic of the next article in this series.
About the author
Rodrigo Arenas is a software architect and machine learning engineer in Medellín, Colombia. He builds products, platforms, and open-source software for AI, Data Engineering, and Machine Learning — creator of Ciaren, sklearn-genetic-opt, and PyWorkforce.
Recommended reading
Rodrigo Arenas — ML Engineer & Open Source Maintainer
Working on something similar? I can help you take it to production.