Parallelization in Python: multiprocessing, joblib, and When More Workers Backfire
1. Why Your Single-Core Loop Is Leaving Performance on the Table
Ever stared at a progress bar crawling along while your laptop fan barely whispers? Your machine might have 8 or 16 cores, but your Python script is probably running on just one.
Picture a kitchen with eight chefs where you’ve only handed a recipe to one. The other seven are standing around with nothing to do. That’s the default behavior of a Python for-loop — it finishes task A, then starts task B, then task C.
Here’s what happens when we run a compute-heavy task — like calculating large squares — sequentially:
import time
import os
def heavy_computation(n):
# Simulate a CPU-bound task
return sum(i * i for i in range(n))
# Let's do this 100 times
data = [10**7] * 100
print(f"Total CPU cores available: {os.cpu_count()}")
start = time.time()
results = [heavy_computation(x) for x in data]
end = time.time()
print(f"Sequential time: {end - start:.2f} seconds")
os.cpu_count()— returns the number of logical CPU cores available on your machine. This is a quick way to see how much parallelism your hardware can theoretically offer.sum(i * i for i in range(n))— this is a generator expression passed directly intosum(). Unlike a list comprehension, it doesn’t build an intermediate list in memory; it yields values one at a time. For largen, this saves RAM.[10**7] * 100— creates a list of 100 identical values, each being 10 million. Python’s list multiplication reuses the same integer object (integers are immutable, so this is safe). This is the “100 identical tasks” we want to process.[heavy_computation(x) for x in data]— a standard list comprehension that runs sequentially. Each call toheavy_computationmust finish before the next one starts. This is the baseline we’re about to beat with parallelism.time.time()— captures wall-clock time. Taking the differenceend - startgives us elapsed seconds. It’s not the most precise profiler, but it’s the simplest way to get a ballpark measurement.
On the machine used to verify this article, os.cpu_count() reports 16, and this takes about 85 seconds. The “Sequential time” tells us how long one core spent doing all the work — with 16 cores available, that’s about 94% of the hardware sitting idle. Run the block yourself and both numbers will be different; what matters is the ratio you measure next, not either absolute number.
2. The Intuition: Splitting Work Across Workers
Parallelization is divide and conquer. Rather than one person handling 100 tasks, you hire four workers and give each of them 25.
In Python, those workers are Processes — separate Python instances, each on its own CPU core. The whole thing works in three steps:
- Splitting: Take your list of 100 items and chop it into chunks.
- Distribution: Send each chunk to a worker process.
- Collection: The workers do the math at the same time, then send the results back to you for combining.
This gets called “embarrassingly parallel” work. The tasks don’t need to talk to each other — calculating the square of 10 doesn’t change how you calculate the square of 20.
3. Meet multiprocessing: The Built-In Parallelizer
Python ships with a library called multiprocessing that handles the chef-hiring for you. The most common tool here is the Pool. A Pool spins up a set of workers that stay ready to receive work.
Here’s the catch: On Windows, multiprocessing uses spawn, which re-imports your main module in every child process. Skip the if __name__ == '__main__': guard around a multiprocessing.Pool() call and each child re-executes that call itself, spawning its own children in a runaway cascade until the machine locks up. joblib’s default backend (loky) does not re-import the parent module the same way, so the two Parallel(...) examples later in this article run safely without the guard — but adding it anyway costs nothing and keeps your code portable if you ever switch backends or libraries, so the examples below include it.
import multiprocessing
import time
def heavy_computation(n):
return sum(i * i for i in range(n))
if __name__ == '__main__':
data = [10**7] * 100
# Create a pool of workers (defaults to number of cores)
start = time.time()
with multiprocessing.Pool() as pool:
# pool.map distributes the 'data' across the workers
results = pool.map(heavy_computation, data)
end = time.time()
print(f"Parallel time with multiprocessing: {end - start:.2f} seconds")
multiprocessing.Pool()— creates a pool of worker processes. With no arguments, it defaults toos.cpu_count()workers. Each worker is a fully separate Python interpreter with its own memory space — this is the key difference from threading, and it’s what lets multiprocessing bypass the GIL (Global Interpreter Lock).with multiprocessing.Pool() as pool:— thewithstatement uses the context manager protocol. When the block exits,Pool.__exit__unconditionally callspool.terminate()— notclose()+join()— which is safe here becausepool.map()already blocks until every result comes back, so there’s nothing left running to terminate abruptly. If you switch toapply_async()without calling.get()yourself, that distinction matters:terminate()won’t wait for those to finish.pool.map(heavy_computation, data)—pool.mapis the parallel equivalent of the built-inmap(). It splitsdatainto chunks, sends each chunk to a worker process, the worker appliesheavy_computationto each item, and results are collected back into a single list in the original order. The distribution and collection are handled for you.if __name__ == '__main__':— this guard is critical on Windows (which usesspawnmode). When Python spawns a new process, it re-imports the main module. Without this guard, themultiprocessing.Pool()call would execute in every child process, causing each child to spawn its own children, creating an infinite cascade of processes. On macOS/Linux (which default tofork), this guard is less critical but still recommended for portability.
On that same 16-core machine, this dropped from 85 seconds to about 15 seconds — a 5.6x speedup. Not a perfect 16x, because there’s a “tax” for moving data around: pickling each task, shipping it to a worker, and collecting the result back. Still a solid win.
4. The Catch: When Parallelization Backfires
More workers aren’t always better. Say you need to wash a single coffee mug. Hiring 10 professional cleaners, signing contracts, briefing them — that takes way longer than just washing the thing yourself.
This is Overhead. Spawning a process takes time, roughly 10-100ms. If your task runs fast, that overhead makes your code slower.
import time
from joblib import Parallel, delayed
# Fast task: just adding two numbers
def fast_task(x):
return x + 1
# 10,000 very fast tasks
data = list(range(10000))
if __name__ == '__main__':
start = time.time()
seq_results = [fast_task(x) for x in data]
print(f"Sequential: {time.time() - start:.4f}s")
# Sequential: 0.0007s
start = time.time()
par_results = Parallel(n_jobs=-1)(delayed(fast_task)(x) for x in data)
print(f"Parallel: {time.time() - start:.4f}s")
# Parallel: 0.8437s -- over 1,000x slower for doing the exact same work
def fast_task(x): return x + 1— a trivially fast function. The actual computation (integer addition) takes nanoseconds. This is the “anti-pattern” for parallelization: the task is so cheap that the overhead of managing the task dwarfs the work itself.list(range(10000))— creates a list of 10,000 integers. Even though we have 10,000 items, each task is so fast that the serialization, inter-process communication, and deserialization overhead per item exceeds the compute time.- Measured, not just predicted: the sequential loop finishes in well under a millisecond, while the parallel version takes nearly a full second — the bottleneck isn’t the computation, it’s the data movement. Pickling the function, sending it to a worker, sending the argument, receiving the result — all of that happens per task, and for
x + 1, that round-trip costs orders of magnitude more than the addition itself.
Rule of thumb: only parallelize if each individual task takes at least 0.1 seconds (100ms). If your tasks are microseconds long, stick with a standard loop.
5. joblib: The Smarter Parallelizer for Data Science
multiprocessing gets the job done, and needs nothing beyond the standard library. joblib trades that dependency-free simplicity for a cleaner call syntax, more informative errors, and built-in progress reporting (verbose logging, not a visual bar) — worth it once you’re past a one-off script and into a pipeline you’ll run more than a few times.
from joblib import Parallel, delayed
import time
def heavy_computation(n):
return sum(i * i for i in range(n))
data = [10**7] * 100
# n_jobs=-1 means 'use all available cores'
# verbose=10 gives us a nice progress update
start = time.time()
results = Parallel(n_jobs=-1, verbose=10)(
delayed(heavy_computation)(x) for x in data
)
end = time.time()
print(f"Joblib parallel time: {end - start:.2f} seconds")
from joblib import Parallel, delayed—Parallelis the main entry point (likemultiprocessing.Pool), anddelayedis a decorator/wrapper that captures a function call without executing it. It’s joblib’s way of saying “remember this function and its arguments; we’ll run it later on a worker.”delayed(heavy_computation)(x)— this two-step call looks odd but is elegant:delayed(heavy_computation)returns a wrapper, and calling that wrapper with(x)records the argument. At no point doesheavy_computationactually run here. Think of it as creating a “job ticket” that says “runheavy_computation(x)when you get to a worker.”Parallel(n_jobs=-1, verbose=10)(...)—Parallelis callable (it implements__call__). You pass it a generator of delayed job tickets.n_jobs=-1means “use all available cores.”verbose=10controls how chatty the progress output is. The result is collected into a list, just likepool.map.delayed(heavy_computation)(x) for x in data— this is a generator expression, not a list comprehension. The jobs are created lazily one at a time, which matters when you have millions of tasks — you don’t want to build a giant list of job tickets in memory before dispatching. The generator feeds jobs toParallelas workers become available.
The delayed(func)(arg) syntax looks odd at first. All it does is tell joblib: don’t run this yet, wait until you’re on a worker core.
6. Controlling Parallelism: n_jobs and Batching
The n_jobs parameter controls how many “chefs” you hire:
n_jobs=1: No parallelization — handy for debugging.n_jobs=2: Two cores.n_jobs=-1: Every core on your machine.
For 1,000,000 tiny tasks, reach for batch_size. Rather than dispatching one task at a time, joblib bundles 1,000 into each “envelope,” cutting the overhead.
7. Real-World Example: Processing a Batch of CSV Files
Reading and cleaning 50 CSV files is a common data science task. Done one-by-one, it’s slow. Your CPU waits for the hard drive to finish each read before moving on.
import pandas as pd
import numpy as np
import time
from joblib import Parallel, delayed
# Let's simulate creating 50 CSV files, sized so each one takes noticeably
# longer than the article's own 100ms parallelize-or-not threshold -- a
# small toy file wouldn't clear that bar, and this section exists to show
# a case where parallelizing genuinely pays off.
for i in range(50):
df = pd.DataFrame(np.random.rand(200_000, 4), columns=['a', 'b', 'c', 'd'])
df.to_csv(f'data_{i}.csv', index=False)
def process_file(filename):
df = pd.read_csv(filename)
# Do some 'cleaning'
result = df['a'].sum()
return result
file_list = [f'data_{i}.csv' for i in range(50)]
if __name__ == '__main__':
start = time.time()
seq_results = [process_file(f) for f in file_list]
print(f"Sequential: {time.time() - start:.2f}s")
# Sequential: 7.54s
start = time.time()
results = Parallel(n_jobs=-1)(delayed(process_file)(f) for f in file_list)
print(f"Parallel: {time.time() - start:.2f}s")
# Parallel: 2.50s -- a 3x speedup, on the very first call with no warm-up
print(f"Processed {len(results)} files. First result: {results[0]:.2f}")
pd.DataFrame(np.random.rand(10000, 4), ...)— creates a 10,000-row × 4-column DataFrame filled with random floats.np.random.randgenerates the raw array, and pandas wraps it into a DataFrame with named columns.df.to_csv(f'data_{i}.csv', index=False)— writes the DataFrame to disk as a CSV file.index=Falseprevents pandas from writing the row index as an extra column (a common source of messy CSVs).f'data_{i}.csv'— f-strings (Python 3.6+) for string interpolation. Here,iis an integer; the f-string converts it to its string representation automatically.Parallel(n_jobs=-1)(delayed(process_file)(f) for f in file_list)— the samedelayedpattern as before, but now each “job” involves file I/O (reading a CSV) plus real parsing work: 200,000 rows takes about 150ms per file to load, comfortably clearing the 100ms rule from Section 4. Because file reading involves waiting on the disk, this is a mix of I/O-bound and CPU-bound work — parallelism helps here both by overlapping disk reads across workers and by letting the CPU work on parsing while other workers are waiting on I/O. (A version of this example using small, quick-to-parse files would land on the wrong side of the 100ms rule and lose to the sequential loop, the way Section 4’sfast_taskdid — file I/O makes something look like it should parallelize well, but the per-file cost is what actually decides it.)df['a'].sum()— pandas vectorized column sum. This is the “cleaning” step (trivial here for illustration). In a real pipeline, this might involve dropping NaNs, type conversion, or filtering — all operations that benefit from each worker having its own DataFrame in memory.
Measured: 7.54 seconds sequential against 2.50 seconds parallel — a 3x speedup, on the very first call, with no pool warm-up needed. That ratio holds roughly steady as the file count grows, so a real pipeline with hundreds of similarly-sized files scales the same way: the sequential run climbs roughly linearly, and parallel stays close to a third of it.
8. Debugging Parallelized Code: Why Your Code Breaks
This is the hard part: code that runs fine in a normal loop often breaks under parallelism. Two main culprits.
- Pickling Errors: Python “pickles” (serializes) your function before sending it to another core. A
lambdaor a function nested inside another function crashes with aPicklingError. Define your worker functions at the top level of your script. - Shared State: No global list that all workers append to. Each worker has its own memory. Worker A changes a global variable, and Worker B never sees the update. Return results from the function rather than modifying a shared variable.
9. Memory Trade-offs: When Parallelization Uses Too Much RAM
Memory Multiplication is the catch.
A 2GB DataFrame sounds manageable. But if you start 8 worker processes, Python may hand each worker its own copy of that 2GB frame. Now you’re at 16GB of RAM, and the machine locks up.
The practical takeaway: with large datasets, don’t pass the whole dataset into the function. Pass a filename or an index instead. Let each worker load only the slice it needs.
10. When to Use multiprocessing vs. joblib vs. Dask
| Tool | Best Use Case | Ease of Use |
|---|---|---|
| multiprocessing | Simple scripts, no extra dependencies | Moderate |
| joblib | Data science pipelines, Scikit-learn tasks | Easiest |
| Dask | Massive datasets that don’t fit on one computer | Advanced |
multiprocessing vs. threading vs. asyncio: Which kind of parallelism do you actually need?
The table above covers the data science tools, but there’s a deeper question: what kind of parallelism does your problem call for? Python offers three fundamentally different mechanisms, and picking the wrong one can make your code slower.
The key distinction: CPU-bound vs. I/O-bound work.
| Mechanism | What it parallelizes | Best for | GIL limited? |
|---|---|---|---|
multiprocessing / joblib | True parallel CPU computation | CPU-bound: math, data transforms, model training | No — each process has its own Python interpreter |
threading | Concurrent I/O waiting | I/O-bound: file reads, HTTP requests, DB queries | Yes — only one thread runs Python at a time |
asyncio | Single-threaded concurrent I/O | I/O-bound with thousands of lightweight waits | Yes — but cooperatively yields, so it scales better for many small waits |
When to use multiprocessing (or joblib):
Your task is CPU-bound — it’s doing actual computation (matrix math, image processing, ML model training). The GIL prevents multiple threads from executing Python bytecode simultaneously, so threading won’t give you a speedup here. You need separate processes, each with its own interpreter and GIL, to truly use multiple cores. This is what the entire article above is about.
When to use threading:
Your task is I/O-bound — it spends most of its time waiting (for disk, network, a database, or an API response). While one thread waits for a network response, another thread can use the CPU to process a different request. Because the GIL is released during I/O operations (and during C-extension calls like NumPy), threading can give real speedups for I/O-bound workloads — without the memory overhead of spawning separate processes.
When to use asyncio:
Same use case as threading (I/O-bound), but when you have thousands of concurrent waits (e.g., polling 5,000 APIs, handling many WebSocket connections). asyncio uses a single thread and an event loop, so it avoids both the GIL contention and the OS-level overhead of creating threads. Each coroutine is a lightweight “task” that yields control back to the event loop when it hits an await. The tradeoff: your code must be written in async/await style throughout, which is a bigger refactor than just wrapping a loop in ThreadPoolExecutor.
Rule of thumb:
- Doing math? →
multiprocessing/joblib(CPU-bound, you need real cores) - Waiting on the network/disk with a moderate number of tasks? →
threadingorconcurrent.futures.ThreadPoolExecutor(I/O-bound, GIL doesn’t matter) - Waiting on the network with thousands of tasks? →
asyncio(I/O-bound at massive scale, single-thread concurrency) - Mix of both? → Use
multiprocessingfor the CPU-heavy part, and threading/asyncio for the I/O-heavy part.
11. Recap: The Parallelization Playbook
- Measure first: Time it with
time.time(). No point optimizing what isn’t slow. - Check the task size: If each item takes under 100ms, don’t parallelize.
- Reach for joblib when you’re past a one-off script: the cleaner syntax and better error messages pay for the dependency once you’re iterating on a pipeline; a single throwaway script has no reason to add it. Either way, set
n_jobs=-1to use all available cores. - Debug with n_jobs=1: If something crashes in parallel, set
n_jobs=1so the real error message surfaces. - Watch your RAM: Don’t pass huge objects to your workers.
Now go put those idle cores to work.
Check Your Understanding
Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.
Remember
Why must Windows users wrap their multiprocessing code in if __name__ == '__main__':?
Understand
In your own words, explain why parallelizing 10,000 very fast tasks (like x + 1) can end up slower than a simple sequential loop, using the article’s “coffee mug” analogy.
Apply Using the article’s rule of thumb (“only parallelize if each individual task takes at least 0.1 seconds”), would you parallelize a task that processes a single small JSON file in about 20ms? What would you do instead, per the article’s batching advice?
Analyze The article’s Memory Multiplication section warns that passing a 2GB DataFrame to 8 worker processes could balloon to 16GB of RAM. Walk through why passing a filename or index instead (and having each worker load its own slice) avoids this multiplication, when the goal is still for every worker to have access to the data it needs.
Evaluate
The article recommends debugging with n_jobs=1 “to see the real error message” when parallel code crashes. Critique why parallel execution specifically tends to obscure error messages compared to sequential execution — what’s different about how an exception in a worker process reaches you compared to one raised directly in your main script?
Create Design a parallelization strategy for a new task: resizing and compressing 5,000 product images (each taking about 300ms) stored across a network drive with limited bandwidth. Using the article’s decision table (multiprocessing vs. joblib vs. Dask) and the task-size rule of thumb, would you parallelize this task, which tool would you pick, and what’s one risk (from the article’s pitfalls) you’d specifically watch for given the network-drive constraint?
Related articles
- Vectorization in Python: Why It’s 100-1000x Faster — Before reaching for
multiprocessing, check whether your loop can be replaced with vectorized NumPy/pandas operations. Often the biggest speedup comes from eliminating the loop entirely, not from parallelizing it. - Why Is My Data Pipeline Crashing? A Friendly Guide) — The Memory Multiplication pitfall in this article is one of the most common causes of data pipeline crashes. This companion article covers the broader landscape of memory issues and how to diagnose them.
References & Further reading
- Python
multiprocessing— Official Documentation — the canonical reference forPool,Process, and thespawnvs.forkstart methods. - Python
concurrent.futures— Official Documentation — the higher-levelThreadPoolExecutorandProcessPoolExecutorAPIs, useful for understanding the threading vs. multiprocessing distinction covered in the tradeoffs toggle.
Apply What You Learned
Scenario: You’re shipping a batch inference service that scores 500 customer records through a trained sklearn model (350 MB on disk). A teammate handed you this script — it crashes on launch with PicklingError: Can't pickle <class 'function'>: attribute lookup … failed.
from joblib import Parallel, delayed
import pandas as pd
model = load_model("model_v3.pkl") # 350 MB sklearn Pipeline
def batch_score(records):
results = Parallel(n_jobs=-1, verbose=5)(
delayed(lambda r: model.predict_proba(pd.DataFrame([r]))[0])(r)
for r in records
)
return results
if __name__ == "__main__":
records = load_records("customers.json")
scores = batch_score(records)
Deliverable: In 150–250 words, (1) identify the one planted bug causing the PicklingError, (2) explain why it fails using the article’s concept of per-worker process serialization, (3) provide the corrected batch_score function using a top-level named worker, and (4) flag the second latent risk the article’s Memory Multiplication section warns about even after your fix — and state how to avoid it.
Rubric:
- Bug identified: the
lambdainsidedelayed(...)is the crash cause — lambdas cannot be pickled for cross-process transport - Root cause explained: each worker is a separate Python interpreter; joblib must pickle both the function and its arguments to ship them across process boundaries, and lambdas / nested functions fail serialization (article §8: “Define your worker functions at the top level of your script”)
- Fix provided: refactors to a top-level named function (e.g.,
def _score_one(r, model_path): …) called viadelayed(_score_one)(r, model_path)— no lambda, no nesteddef - Second risk flagged: passing the 350 MB
modelobject as an argument pickles it once per worker → 8 workers × 350 MB ≈ 2.8 GB, matching the article’s “2 GB × 8 = 16 GB” Memory Multiplication pattern; the fix is to passmodel_pathas a string and let each worker load its own copy from disk, following the article’s rule: “Pass a filename or an index instead. Let each worker load only the slice it needs.” - Output preserved: the corrected code produces the same
predict_probaresults as the intended sequential version — no silent change in scoring logic
Related articles
- Python Engineering Under review
Why Is My Pandas Code So Slow? A Practical Guide to Vectorization
Learn why row-by-row loops make Pandas painfully slow, and how vectorized arithmetic can run up to 10,000x faster — plus the real, measured speedups np.select and groupby deliver over the apply()/loop code they replace.
- Python Engineering Under review
Vectorization in Python: Why It's 100–1000x Faster Than Loops
Learn how to replace slow Python loops with NumPy vectorized operations for 100–1000x speedups using SIMD, broadcasting, boolean masking, and Pandas built-ins.
- Python Engineering Under review
Why is My Data Pipeline Crashing? A Friendly Guide to Python Memory Profiling
Learn to diagnose and fix Python MemoryError crashes in data pipelines using memory_profiler, Fil, and chunking to handle massive datasets on limited RAM.
- Python Engineering Under review
Python Generators: How to Process Massive Datasets Without Crashing Your Computer
Learn how Python generators and the yield keyword let you stream massive datasets in constant memory, avoiding MemoryError without loading everything into RAM.
Looking for something else?
Search every article by title, summary or topic.