Python multiprocessing vs multithreading comes down to one question that most comparisons answer too late: whether your bottleneck is the CPU doing work or the program waiting for something else to finish. Threads are cheap and share memory but have historically been unable to run Python bytecode in parallel, while processes achieve real parallelism and pay for it in memory and in the cost of moving data between them.
That first clause has now changed, which is why advice written before Python 3.14 needs rereading and why 3.15 is worth checking again. Free-threaded builds exist, they are no longer experimental, and they alter the calculation for a specific class of workload without removing the reasons processes still win elsewhere.
What is the difference between multiprocessing and multithreading in Python?
Multithreading runs several threads inside one interpreter sharing the same memory, which makes communication free and corruption easy. Multiprocessing runs separate interpreters in separate memory spaces, which makes corruption impossible and communication expensive. The choice is a trade between cheap sharing and true isolation, and your workload decides which cost you can afford.
The practical consequences show up in places people forget to check. Anything passed between processes must be serialisable, which rules out open connections, file handles and many objects holding references to them. Threads have no such restriction but acquire a different problem, since two threads touching the same structure without a lock produce bugs that appear only under load.
| Threads | Processes | |
|---|---|---|
| Memory | Shared, one address space | Isolated, one copy each |
| Startup cost | Low | Noticeably higher |
| Data exchange | Free, by reference | Serialised, by copy |
| Failure blast radius | One thread can corrupt all | Crash stays contained |
| Parallel Python bytecode | Only on free-threaded builds | Yes |
What does the GIL actually lock?
The global interpreter lock permits only one thread to execute Python bytecode at a time within a single interpreter. It does not lock input and output, so a thread waiting on a network response, a disk read or a database query releases the lock and lets another thread run. That distinction is the whole reason threads remain useful despite it.
Most of the confusion comes from treating the GIL as a blanket ban on threading. It is not. It is a ban on two threads interpreting Python simultaneously. Extension code that releases the lock, which includes much of NumPy and most I/O libraries, runs in genuine parallel under threads, which is why numerical work often threads better than people expect.
Has free-threaded Python changed the answer?
Yes, for CPU-bound pure-Python work, with a cost attached. Free-threaded builds remove the lock so threads execute bytecode in parallel, which is what multiprocessing existed to achieve. The Python 3.15 release notes record that official macOS binaries now install free-threading by default, and that C extensions can target a stable ABI for free-threaded builds.
The price is measured and worth knowing before committing. PEP 779, which set the criteria for supported status in 3.14, recorded the linear performance cost at around 10 percent against the standard build, about 3 percent on macOS, and memory use roughly 15 to 20 percent higher, with proposed targets of 15 and 20 percent for the supported phase. Those figures predate the 3.15 work, so measure on your own build rather than assuming them.
That trade reframes the decision rather than ending it. If a workload is CPU-bound, pure Python, and needs shared state, free-threading now offers parallelism without serialisation cost. If it is already comfortable in processes and does not share much, paying 10 percent on every operation to avoid pickling you were not doing anyway is a poor bargain.
When should you use threads instead of processes?
Use threads when the program spends most of its time waiting, and processes when it spends most of its time computing in Python. Waiting covers network calls, database queries, file reads and anything hitting an external service. Computing covers parsing, transforming, encoding, numerical work in pure Python and anything that keeps a core busy.
The failure mode is applying the wrong one at scale. Threads on a CPU-saturated workload add context switching to an already busy core and make throughput worse, not better. Processes on an I/O-heavy workload spend more on startup and serialisation than the work itself costs, and the memory footprint grows with a worker count that was never the constraint.
| Workload shape | Use | Why |
|---|---|---|
| Many concurrent network calls | Threads or async | Waiting dominates, the lock is released |
| Heavy pure-Python computation | Processes, or free-threaded builds | Needs real bytecode parallelism |
| NumPy or C-extension numerical work | Threads | Extensions release the lock |
| Mixed, with a CPU step inside a request | Async with a process pool | Keep the event loop free |
Where does async/await fit?
Async suits very high concurrency over slow connections, where thousands of simultaneous waits would mean thousands of threads. One event loop on one thread handles them with far less memory, because a pending coroutine costs a fraction of what a stack costs. For a few dozen concurrent calls, threads are simpler and perform comparably.
The rule that keeps async working is that nothing may block the loop. A single synchronous call in a coroutine stalls every other task on that loop, and the symptom appears as unexplained latency across unrelated requests. CPU work inside an async service belongs in an executor:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(process_pool, cpu_heavy_fn, payload)
How do you choose between them?
Measure the split before choosing. Profile a representative run, establish what fraction of wall-clock time is spent waiting versus computing, and check whether a core is actually saturated. If waiting dominates, go concurrent with threads or async. If computing dominates, go parallel with processes or a free-threaded build.
Guessing is expensive here because both wrong answers look plausible in development. A workload tested with ten items appears to behave under either model, and the difference only emerges at the volume where one of them stops scaling. Running the measurement on production-shaped data is what makes the decision defensible.
The one number that decides it. If a single core sits near saturation during a representative run, threads on the standard build cannot help you, no matter how many you add. Everything else is a secondary consideration.
How do you benchmark concurrency choices?
Run the same workload through both models on identical hardware with identical input, allow a warm-up before measuring, then compare throughput, latency at the tail, peak memory and error rate together. A model that wins on throughput while tripling memory or inflating the 99th percentile has not won.
Measure the tail specifically, because averages hide the behaviour that generates incidents. Process pools often show better medians and worse tails under memory pressure, while thread pools degrade more gradually and then collapse at saturation. Both patterns matter operationally and neither is visible in a mean.
What to capture on each run:
- Throughput at steady state, not including warm-up
- Latency at the median and the 99th percentile
- Peak resident memory per worker and in total
- Error and retry counts under sustained load
- CPU utilisation per core, to confirm saturation
What goes wrong in production?
The recurring failures are unbounded queues, shared mutable state without locks, unserialisable objects crossing process boundaries, and fork-related surprises on platforms where the start method differs. Each produces a distinctive symptom, and each is preventable with a decision made before the code is written rather than after an incident.
Start method deserves particular attention because the default varies and the behaviour differs materially. Forking copies the parent's state, including locks that may be held, and inherited connections that are no longer valid. Setting it explicitly makes behaviour identical across environments:
multiprocessing.set_start_method("spawn", force=True)
Queue bounds are the other control worth setting on day one. An unbounded queue turns a downstream slowdown into memory exhaustion, and the process dies for a reason that looks unrelated to the actual cause. Backend systems built on worker pools need this designed in, which our backend development services page covers in the context of wider system design.
Working with Python specialists
Concurrency bugs are the ones least likely to surface in review and most likely to surface at peak load, which makes prior experience of this specific class of problem worth more than general seniority.
Our network includes Python engineers matched against your workload shape, your deployment target and the concurrency model you are actually running.












