Half a dozen libraries all promise "parallelism." They aren't rivals so much as layers โ some are thin sugar over the same primitive, some scale to a thousand-node cluster, and one doesn't parallelize Python at all. This is the map of which to reach for.
Every library below gets you "more than one thing at once," but they sit at very different altitudes.
At the bottom is one primitive โ the stdlib multiprocessing module โ and
almost everything else is either a friendlier wrapper over it or a bigger system that competes with it.
Two axes separate them:
multiprocessing)
gives you raw control and maximum verbosity; high-level (concurrent.futures,
joblib) gives you one clean call.concurrent.futures covers most single-machine work; you climb to
Dask/Ray only when the data or compute genuinely outgrows one box, and you drop to raw
multiprocessing only when you need a primitive the high-level API doesn't expose.
multiprocessing
multiprocessing is the primitive everything else stands on. It ships in the standard
library (no install) and hands you the raw building blocks: Process to launch a worker,
Pool for a batch of them, Queue/Pipe to pass messages,
shared_memory for zero-copy buffers, and the full set of sync primitives
(Lock, Semaphore, Event). It gives you maximum
control at the cost of being the most verbose, and it moves data with the
built-in pickle โ so arguments and results must be picklable.
import multiprocessing as mp
def square(x):
return x * x
if __name__ == "__main__": # required on spawn platforms
with mp.Pool(processes=4) as pool: # classic worker pool
results = pool.map(square, range(10))
print(results) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
Queue pipelines, a long-lived Manager, a specific start method, or
shared_memory buffers. For the ergonomic tour of the pool see
multiprocessing.Pool & map. In everyday code you
usually want the friendlier wrapper below, not this directly.
concurrent.futures
Also stdlib, but a layer up. concurrent.futures exposes one clean
Executor abstraction with two backends โ
ProcessPoolExecutor (real parallelism, separate processes) and
ThreadPoolExecutor (shared memory, for I/O). You submit() work and get a
Future back, or map() over an iterable. Results come out of
.result(), and โ critically โ exceptions raised in a worker are re-raised
there, so errors surface cleanly instead of vanishing into a child process.
from concurrent.futures import ProcessPoolExecutor
def square(x):
return x * x
if __name__ == "__main__":
with ProcessPoolExecutor() as pool: # defaults to os.cpu_count() workers
futures = [pool.submit(square, x) for x in range(10)]
results = [f.result() for f in futures] # re-raises worker exceptions here
print(results)
ProcessPoolExecutor for CPU-bound work, ThreadPoolExecutor for I/O-bound โ
the rest of your code is identical. That makes it trivial to A/B a workload. Full walkthrough in the
ProcessPoolExecutor pattern.
concurrent.futures is the right answer: stdlib, tiny API, clean error handling, and no
dependency to install.
joblib (and loky under it)
joblib is a third-party library (pip install joblib) beloved in the
NumPy/scikit-learn world โ it's what powers n_jobs=-1 across sklearn. Its signature idiom
is a one-liner list comprehension:
from joblib import Parallel, delayed
def square(x):
return x * x
# n_jobs=-1 means "use every core"
results = Parallel(n_jobs=-1)(delayed(square)(x) for x in range(10))
print(results)
delayed(fn)(x) just packages the call for later; Parallel dispatches the
batch. By default it runs on the loky backend โ a robust reusable
process pool โ but you can switch to threading or the classic multiprocessing
backend with one argument. Three features earn it its fans:
cloudpickle serialization โ unlike stdlib pickle, it can
serialize lambdas and closures, so the picky "can't pickle a local function" errors from
Pattern 8: Pickling mostly disappear.joblib.Memory โ memoize expensive
function results to disk across runs.from joblib import Parallel, delayed
# closures & lambdas work here (cloudpickle) โ they would fail with stdlib pickle
factor = 3
results = Parallel(n_jobs=-1, backend="loky")(
delayed(lambda x: x * factor)(x) for x in range(10)
)
joblib fits like a glove.
loky, briefly
loky is the standalone executor joblib is built on. It's a drop-in,
concurrent.futures-style pool (get_reusable_executor()) that is more
robust to worker crashes and reuses workers across batches instead of
respawning them. You rarely import it directly โ you get its benefits for free through
joblib โ but it's why joblib's pool tolerates segfaulting native code better than a plain
ProcessPoolExecutor does.
subprocess
subprocess is stdlib and often confused with multiprocessing โ but it does
something completely different. It does not parallelize Python. It launches an
external program: a compiled binary, a shell command, ffmpeg,
git, or another script. The "work" isn't a Python function you're fanning out across
cores โ it's a separate executable you're driving from Python.
import subprocess
# run an EXTERNAL program and capture its output
result = subprocess.run(
["ffmpeg", "-i", "in.mov", "out.mp4"],
capture_output=True,
text=True,
check=True, # raise CalledProcessError on non-zero exit
)
print(result.stdout)
multiprocessing runs
your Python function in another Python interpreter. subprocess runs
some other program entirely and talks to it over stdin/stdout/exit codes. If the thing you
want to parallelize is a Python function โ multiprocessing / concurrent.futures.
If it's an external command โ subprocess. (You can, of course, fan out many
subprocess calls from a thread pool โ the two compose.)
dask & rayWhen one machine's cores or RAM aren't enough, you climb to a distributed framework. Both are heavy third-party dependencies and both scale from a laptop to a cluster โ but they have different shapes.
dask โ parallel arrays, DataFrames, and lazy task graphs
dask mirrors familiar APIs: dask.array looks like NumPy and
dask.dataframe looks like pandas, but the data is chunked and operations
build a lazy task graph that only executes on .compute(). That enables
out-of-core work โ datasets larger than RAM, processed chunk by chunk โ plus a general
dask.delayed / futures API for arbitrary functions.
import dask
@dask.delayed
def square(x):
return x * x
# builds a lazy graph; nothing runs until .compute()
graph = [square(x) for x in range(10)]
results = dask.compute(*graph) # executes across threads / processes / a cluster
print(results)
ray โ distributed tasks and stateful actors
ray targets distributed compute for Python and ML. Decorate a function with
@ray.remote to turn it into a task, or decorate a class to get a
stateful actor running on some node in the cluster. Its object store
keeps large shared objects in memory and hands out references, so data isn't re-pickled for every task
that touches it.
import ray
ray.init() # connects to a local or remote cluster
@ray.remote
def square(x):
return x * x
futures = [square.remote(x) for x in range(10)] # returns ObjectRefs
results = ray.get(futures) # gather actual values
print(results)
mpi4py
mpi4py is Python bindings for MPI (Message Passing Interface), the
decades-old standard for high-performance computing across supercomputer nodes. The model is explicit:
every process has a rank, and you send/receive messages over a
communicator. You launch it with a job scheduler (mpirun/srun),
not from inside a single Python program.
# run with: mpirun -n 4 python squares.py
from mpi4py import MPI
comm = MPI.COMM_WORLD
rank = comm.Get_rank() # which process am I?
size = comm.Get_size() # how many are there?
result = rank * rank # each rank squares its own id
gathered = comm.gather(result, root=0) # rank 0 collects everything
if rank == 0:
print(gathered) # [0, 1, 4, 9]
mpi4py only on an actual HPC cluster with
a scheduler and a system MPI installed. For most people it's the wrong tool โ but if you're on a
supercomputer, it's the tool.
multiprocessing.shared_memory
Worth calling out on its own: multiprocessing.shared_memory is a stdlib primitive that
gives multiple processes a single, zero-copy block of RAM โ no pickling, no copying.
It's the mechanism several of the libraries above lean on (joblib's array memory-mapping, Ray's object
store solve the same "don't copy the big buffer" problem). You'll usually meet it through a higher-level
library, but when you're hand-rolling multiprocessing and a large NumPy array is the
bottleneck, it's the escape hatch. Deep dive in
Pattern 4: Shared Memory.
The clearest way to feel the ergonomic differences: one identical embarrassingly-parallel task โ
square(x) over a list โ expressed in four libraries. The work is the same every
time; only the wrapper changes.
concurrent.futures.ProcessPoolExecutor โ stdlib defaultfrom concurrent.futures import ProcessPoolExecutor
def square(x):
return x * x
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
results = list(pool.map(square, range(10)))
joblib.Parallel + delayed โ the numeric one-linerfrom joblib import Parallel, delayed
def square(x):
return x * x
results = Parallel(n_jobs=-1)(delayed(square)(x) for x in range(10))
dask.delayed โ same call, but a lazy graph that can span a clusterimport dask
@dask.delayed
def square(x):
return x * x
results = dask.compute(*[square(x) for x in range(10)])
ray โ .remote tasks, gathered via the object storeimport ray
ray.init()
@ray.remote
def square(x):
return x * x
results = ray.get([square.remote(x) for x in range(10)])
joblib is the tersest; concurrent.futures is dependency-free and needs
the __main__ guard; dask/ray look almost identical for a toy task
but carry a whole distributed runtime behind them โ you don't pay that weight for ten integers, you pay
it when the ten becomes ten billion across a cluster.
| Library | Layer | Best for | API style | Cluster? | Serialization | stdlib? | Learning curve |
|---|---|---|---|---|---|---|---|
multiprocessing |
Low | Full control, custom IPC | Process/Pool/Queue |
โ | pickle | โ | Medium |
concurrent.futures |
High | Most single-machine work | Executor + Future |
โ | pickle | โ | Low |
joblib |
High | NumPy/sklearn numeric loops | Parallel+delayed |
โณ (with backend) | cloudpickle | โ | Low |
loky |
High | Crash-robust reusable pool | Executor-style |
โ | cloudpickle | โ | Low |
subprocess |
โ | Running external programs | run([...]) |
โ | bytes / stdio | โ | Low |
dask |
Distributed | Out-of-core arrays/DataFrames | lazy graph + delayed |
โ | cloudpickle | โ | Medium |
ray |
Distributed | Distributed actors / ML | @ray.remote |
โ | Arrow object store | โ | Medium/High |
mpi4py |
Distributed | HPC clusters | rank + communicator | โ | MPI messages | โ | High |
subprocess. It's not parallelizing Python at
all; it's driving another executable.joblib. It handles
array memory-mapping and cloudpickle for you.concurrent.futures.
The stdlib default.dask for data-parallel NumPy/pandas
work, ray for distributed actors/ML โ and mpi4py only on a real HPC
cluster.concurrent.futures is the right answer โ or joblib when the work is numeric.
Distributed frameworks (Dask, Ray, mpi4py) are powerful, but they add real operational weight: a
cluster to run, a runtime to manage, more ways to fail. Don't jump to a distributed framework
prematurely. Reach for one only when you've genuinely outgrown a single machine's cores or RAM,
not because it sounds more serious. Start at the lowest rung of the ladder that solves your problem.
requirements.txt too.
multiprocessing, concurrent.futures, and subprocess are
stdlib โ nothing to install, available on any Python. The rest are
pip install third-party: joblib/loky are light and painless;
dask and ray are heavy dependencies that pull in large
dependency trees; and mpi4py won't even build without a system MPI
(OpenMPI/MPICH) installed first. If a stdlib option covers your need, it's one fewer thing to install,
pin, and debug in CI.
For the broader "should I even be using processes?" question โ the serialization tax, when threads or async beat processes โ see Processes vs Threads vs Async, and start from the top with Processes 101.