How to migrate from PuLP 3.x to 4.0
PuLP 4.0 moves the model into a Rust core. Most modelling code keeps working:
prob += ..., lpSum, lpDot, value, constraint names, writeLP,
writeMPS and the solver classes are the same. The breaking changes are in
four places:
variables are created by the problem (
prob.add_variable(...)), not withLpVariable(...),prob.constraintsis a method that returns a list, not a dict,solve()returns anLpSolveStatsobject instead of an integer, and the status constants are replaced by theLpSolveStatusenum,CBC is no longer bundled:
PULP_CBC_CMDis gone, useCOIN_CMD.
This guide lists every breaking change with the code to write instead.
Before you upgrade: run 3.3.2 with warnings on
PuLP 3.3.1 and 3.3.2 emit a DeprecationWarning for most of the calls that
4.0 removes, and they already include the new methods (add_variable,
add_variable_dicts, constraints()…). The smoothest path is:
Install
pulp==3.3.2and run your code (or your tests) with warnings turned into errors:python -W error::DeprecationWarning my_model.py
Fix every warning using the sections below. Your code now runs on both 3.3.2 and 4.0 for the modelling part.
Upgrade to 4.0 and update the code that reads the solve result (see Solving and reading the status), which cannot be written in a way that works on both versions.
Remove any call to set_v4_migration_warnings(...) when you upgrade: the
function does not exist in 4.0.
Quick reference
PuLP 3.x |
PuLP 4.0 |
|---|---|
|
|
|
|
|
|
|
|
|
not needed: variables belong to the problem that created them |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Installation
PuLP 4.0 requires Python 3.12 or newer.
PuLP 4.0 ships a compiled extension, so it is installed as a platform wheel. It also depends on orloge, which is installed automatically and is used to read solver logs.
CBC is no longer bundled and
PULP_CBC_CMDhas been removed. Install CBC with thecbcextra (or put acbcexecutable on yourPATH) and useCOIN_CMD:python -m pip install pulp[cbc]
# 3.x prob.solve(PULP_CBC_CMD(msg=False, timeLimit=60)) # 4.0 prob.solve(COIN_CMD(msg=False, timeLimit=60))
COIN_CMDaccepts the same argumentsPULP_CBC_CMDdid.getSolver("PULP_CBC_CMD")and solver JSON files with"solver": "PULP_CBC_CMD"must be changed toCOIN_CMDtoo.prob.solve()with no solver still uses CBC when it is available, and otherwise the first other solver it finds. RunlistSolvers(onlyAvailable=True)to see which solvers PuLP can find.
Creating variables
In 3.x a variable was a free-standing object that joined a problem the first
time it appeared in the objective or a constraint. In 4.0 a variable is created
by the problem and belongs to it. LpVariable(...) can no longer be called
directly (it raises TypeError), and LpVariable.dicts, LpVariable.dict
and LpVariable.matrix have been removed.
Every method takes the same arguments as its 3.x counterpart (name,
indices, lowBound, upBound, cat) and names the variables the same
way.
# 3.x
prob = LpProblem("example", LpMinimize)
x = LpVariable("x", lowBound=0, upBound=10)
y = LpVariable("y", cat=LpBinary)
flows = LpVariable.dicts("flow", (warehouses, bars), lowBound=0, cat=LpInteger)
assign = LpVariable.dict("assign", (people, tables), cat=LpBinary)
grid = LpVariable.matrix("grid", (rows, cols), lowBound=0)
# 4.0
prob = LpProblem("example", LpMinimize)
x = prob.add_variable("x", lowBound=0, upBound=10)
y = prob.add_variable("y", cat=LpBinary)
flows = prob.add_variable_dicts("flow", (warehouses, bars), lowBound=0, cat=LpInteger)
assign = prob.add_variable_dict("assign", (people, tables), cat=LpBinary)
grid = prob.add_variable_matrix("grid", (rows, cols), lowBound=0)
This means the problem must exist before its variables. Code that created the variables first and the problem later needs to be reordered, and helper functions that build variables need the problem passed in:
# 3.x
def make_vars(items):
return LpVariable.dicts("buy", items, cat=LpBinary)
# 4.0
def make_vars(prob, items):
return prob.add_variable_dicts("buy", items, cat=LpBinary)
Other changes around variables:
prob.addVariable(x)andprob.addVariables(xs)have been removed. They are not needed: a variable is in the problem from the moment it is created, even if no constraint uses it yet.A variable cannot be used in another problem. Adding an expression with a variable from problem
Ato problemBraisesPulpError: Expression is bound to a different model. If you built several problems from the same variables, create the variables in each problem.prob.copy()andprob.deepcopy()now copy the whole model, variables included. Use the copy’s own variables, looked up by name:# 3.x other = prob.copy() other += x <= 1 # x shared by both problems # 4.0 other = prob.copy() other_vars = other.variablesDict() other += other_vars["x"] <= 1
Binary variables are stored as integer variables with bounds 0 and 1, so
y.catreturns"Integer"for a variable created withcat=LpBinary. Usey.isBinary()to check for binaries.LpVariable.fromDict(data)andLpVariable.fromDataclass(mps)now take the problem as their first argument:LpVariable.fromDict(prob, data).Column-wise modelling (
LpConstraintVarandLpVariable(..., e=...)) has been removed. Build the constraints row by row instead.
Expressions and constraints
Operators work as before: 2 * x + y, lpSum(...), lpDot(...),
x + y <= 5, prob += expr, "name" and prob.addConstraint(expr, "name")
need no changes.
What changed is building expressions and constraints through their constructors, which now only wrap internal objects:
# 3.x
e = LpAffineExpression({x: 1, y: 2}, constant=3, name="e")
e = LpAffineExpression([(x, 1), (y, 2)])
c = LpConstraint(e, LpConstraintLE, "cap", 10)
prob += c
# 4.0
e = LpAffineExpression.from_dict({x: 1, y: 2}, constant=3, name="e")
e = LpAffineExpression.from_list([(x, 1), (y, 2)])
prob += e <= 10, "cap"
LpAffineExpression also has empty(), from_variable(x) and
from_constant(3).
Reading constraints back
prob.constraints is now a method returning the constraints as a list, in
the order they were added. Indexing it by name raises TypeError, and
calling .items(), .values() or .keys() on it raises AttributeError.
# 3.x
for name, c in prob.constraints.items():
print(name, c.pi, c.slack)
cap = prob.constraints["cap"]
# 4.0
for c in prob.constraints():
print(c.name, c.pi, c.slack)
cap = prob.get_constraint_by_name("cap")
get_constraint_by_name returns None when there is no constraint with
that name. It is also available in PuLP 3.3.1 and later, so you can switch to it
before upgrading. If you look up many constraints by name, build a dictionary
once after the model is complete:
constraints = {c.name: c for c in prob.constraints()}.
Removed problem features
These 3.x features have no direct equivalent in 4.0:
Elastic constraints:
LpConstraint.makeElasticSubProblem,FixedElasticSubProblem,FractionElasticSubProblemandLpProblem.extend. Model the penalty explicitly: add non-negative slack variables to the constraint and a penalty on them in the objective.LpFractionConstraintandLpElement.LpProblem.normalisedNames,unusedConstraintName,startClock/stopClock,assignStatus.
Solving and reading the status
This is the change that affects almost every script. In 3.x prob.solve()
returned an integer and stored the status on the problem. In 4.0 it returns an
LpSolveStats object, and the problem no longer has
status, sol_status, solutionTime, solutionCpuTime or
bestBound attributes.
# 3.x
prob.solve(COIN_CMD(msg=False))
print("Status:", LpStatus[prob.status])
if prob.status == LpStatusOptimal:
print(value(prob.objective), prob.solutionTime)
# 4.0
stats = prob.solve(COIN_CMD(msg=False))
print("Status:", stats.status_str)
if stats.status == LpSolveStatus.Optimal:
print(stats.objective, stats.time)
solver.solve(prob) and prob.resolve() return an LpSolveStats too, and
prob.sequentialSolve(...) returns a list of them, one per objective.
Variable values, value(prob.objective), c.pi and c.slack are read the
same way as before.
The status enum
The LpStatus* constants and the LpStatus dictionary are replaced by the
LpSolveStatus enum. It is an IntEnum and keeps
the old numbers, so a comparison with a plain integer (stats.status == 1)
still works.
PuLP 3.x |
PuLP 4.0 |
Value |
|---|---|---|
|
|
0 |
|
|
1 |
|
|
-1 |
|
|
-2 |
|
|
-3 |
(new) |
|
-4 |
(new) |
|
-5 |
(new) |
|
-6 |
(new) |
|
-7 |
(new) |
|
-8 |
(new) |
|
-9 |
(new) |
|
-10 |
(new) |
|
-11 |
(new) |
|
-12 |
stats.status_str is the member’s name, so the text changes slightly:
"Not Solved" becomes "NotSolved". If you compared LpStatus[...]
strings, compare the enum instead. To turn a stored integer into a name, use
LpSolveStatus(code).name.
“Optimal” now means optimal
In 3.x a solver that stopped at a time limit with a feasible solution usually
reported LpStatusOptimal (1), and only prob.sol_status told you the
solution was not proven optimal. In 4.0 the status says why the solver stopped
(TimeLimit, GapLimit, NodeLimit…), and whether it returned a
solution is a separate flag, stats.has_solution.
So code like this, which used to accept time-limited solutions, now treats them as failures:
# 3.x: also true for a feasible solution found before the time limit
if prob.status == LpStatusOptimal:
use_solution()
Decide which of the two you mean:
# 4.0: any feasible solution the solver returned
if stats.has_solution:
use_solution()
# 4.0: only proven optimal solutions
if stats.status == LpSolveStatus.Optimal:
use_solution()
The LpSolution* constants (LpSolutionOptimal, LpSolutionIntegerFeasible,
LpSolutionNoSolutionFound, …) and LpStatusToSolution have been removed;
use has_solution together with status:
PuLP 3.x |
PuLP 4.0 |
|---|---|
|
|
|
|
|
|
What else is in LpSolveStats
Besides status and has_solution, the object has:
solver,time,cpu_time,objective,best_bound,gap_abs,gap_rel,num_variables,num_constraints,is_mip,solver_options.For solvers whose log PuLP can read (
COIN_CMD,CPLEX_CMD,CPLEX_PY,GUROBI,GUROBI_CMD,CPSAT):solver_version,nodes,root_time,presolve,matrix,cut_info,first_solutionand the full parsed log inlogs. These areNonefor the other solvers.
print(stats) shows a short summary, and stats.toDict() /
stats.toJson(path) export it.
Saving and loading problems
LpProblemobjects cannot be pickled any more (pickle.dumps(prob)raisesTypeError), and neither can objects that hold one, such asmultiprocessingarguments. Pass the model as a dictionary or JSON and rebuild it on the other side:data = prob.toDict() # or prob.toJson("model.json") variables, prob = LpProblem.fromDict(data) # or LpProblem.fromJson("model.json")
variablesis a dictionary from variable name to the new problem’s variables.JSON files written by 3.x load in 4.0. Files written by 4.0 no longer contain
statusandsol_status, so PuLP 3.x cannot read them.LpProblem.to_dict,from_dict,to_jsonandfrom_jsonstill work but are deprecated; usetoDict,fromDict,toJsonandfromJson.
Solver modules and custom solvers
Most code imports solvers from pulp directly and needs no change. If you
import from the solver modules, drop the _api suffix:
# 3.x
from pulp.apis.coin_api import COIN_CMD
from pulp.apis.gurobi_api import GUROBI
# 4.0
from pulp.apis.coin import COIN_CMD
from pulp.apis.gurobi import GUROBI
If you wrote your own solver class by subclassing LpSolver or
LpSolver_CMD:
actualSolve(lp)must return anLpSolveStats. Build it withself.buildStats(lp, status, has_solution, start=start), wherestartisclocks()(frompulp.apis.core) taken at the beginning ofactualSolve. Do not calllp.assignStatus(removed).The API solvers’
findSolutionValues(lp)returns a(status, has_solution)tuple instead of a status.Register the class with
pulp.apis.addSolver(MySolver)so thatgetSolver("MY_SOLVER")andlistSolvers()can find it.
See How to add a new solver to PuLP for a complete example.
Removed top-level names
These names can no longer be imported from pulp:
PULP_CBC_CMD: useCOIN_CMD.LpStatus,LpStatusNotSolved,LpStatusOptimal,LpStatusInfeasible,LpStatusUnbounded,LpStatusUndefined: useLpSolveStatus.LpSolution,LpSolutionNoSolutionFound,LpSolutionOptimal,LpSolutionIntegerFeasible,LpSolutionInfeasible,LpSolutionUnbounded,LpStatusToSolution: useLpSolveStats.has_solution.LpConstraintVar,LpElement,LpFractionConstraint,FixedElasticSubProblem,FractionElasticSubProblem.set_v4_migration_warnings,pulpTestAll,configSolvers,clock,VERSION: usepulp.__version__for the version.
A complete example
# PuLP 3.x
from pulp import *
prob = LpProblem("production", LpMaximize)
products = ["A", "B"]
make = LpVariable.dicts("make", products, lowBound=0, cat=LpInteger)
prob += 3 * make["A"] + 5 * make["B"], "profit"
prob += 2 * make["A"] + 4 * make["B"] <= 40, "machine_hours"
prob += make["A"] + make["B"] <= 15, "labour"
prob.solve(PULP_CBC_CMD(msg=False, timeLimit=30))
print(LpStatus[prob.status], value(prob.objective))
if prob.status == LpStatusOptimal:
print({p: make[p].varValue for p in products})
print(prob.constraints["labour"].slack)
# PuLP 4.0
from pulp import *
prob = LpProblem("production", LpMaximize)
products = ["A", "B"]
make = prob.add_variable_dicts("make", products, lowBound=0, cat=LpInteger)
prob += 3 * make["A"] + 5 * make["B"], "profit"
prob += 2 * make["A"] + 4 * make["B"] <= 40, "machine_hours"
prob += make["A"] + make["B"] <= 15, "labour"
stats = prob.solve(COIN_CMD(msg=False, timeLimit=30))
print(stats.status_str, stats.objective)
if stats.has_solution:
print({p: make[p].varValue for p in products})
print(prob.get_constraint_by_name("labour").slack)