When should you use threading versus multiprocessing in Python?
Use threading for I/O-bound work — network calls, file reads, database queries — because threads release the GIL during blocking syscalls and share memory cheaply. Use multiprocessing for CPU-bound work — number crunching, image processing — because each process gets its own GIL and can run on a separate core.
How to think about it
One question settles almost every case: is the bottleneck waiting for something external, or computing something hard?
- Waiting — network, disk, a database — → threading.
- Computing — matrix math, parsing, image processing — → multiprocessing.
It all traces back to the GIL. Threads share one GIL and take turns running bytecode, which is perfect for waiting in parallel and useless for true CPU parallelism. Processes each get their own GIL and run on separate cores.
I/O-BOUND WORK CPU-BOUND WORK
────────────────────────── ────────────────────────────
threading multiprocessing
Process Process 1 Process 2
┌──────────────────────┐ ┌──────┐ ┌──────┐
│ Thread1 Thread2 │ │ GIL │ │ GIL │
│ [run] [waiting...] │ │ Core1│ │ Core2│
│ [wait] [run] │ └──────┘ └──────┘
└──────────────────────┘
one GIL, shared memory separate GILs, true parallel
Threading — for I/O-bound work
The GIL is released the instant a thread hits a blocking syscall — a socket read, a file write, time.sleep. So while Thread A waits on a response, Thread B runs Python. Threads share the process’s memory, which makes passing data between them zero-copy — but that same sharing is why you need a Lock or a queue when you mutate shared state.
from concurrent.futures import ThreadPoolExecutor
import urllib.request
def fetch(url):
with urllib.request.urlopen(url) as r:
return len(r.read())
urls = ["https://example.com"] * 10
with ThreadPoolExecutor(max_workers=10) as ex:
results = list(ex.map(fetch, urls)) # ten waits overlap
Multiprocessing — for CPU-bound work
Each Process is a separate OS process with its own interpreter and GIL, so every worker runs on its own core in genuine parallel. The price is that arguments and results are pickled across a pipe, so there’s transfer overhead — worth it when the compute dwarfs the copying. Handily, ProcessPoolExecutor mirrors the ThreadPoolExecutor API, so switching is almost a one-word change:
from concurrent.futures import ProcessPoolExecutor
def crunch(n):
return sum(i * i for i in range(n))
with ProcessPoolExecutor() as ex:
results = list(ex.map(crunch, [10_000_000] * 4)) # four cores at once
Side by side
| Dimension | threading | multiprocessing |
|---|---|---|
| GIL | shared — serialises CPU | separate — true parallel |
| memory | shared address space | copied / pickled |
| overhead | low (OS threads) | higher (process fork + pickle) |
| best for | I/O-bound | CPU-bound |
| crash isolation | none — one thread can take down all | strong — process boundaries |