datarekha
Python Medium Asked at UberAsked at NetflixAsked at AirbnbAsked at Amazon

When should you use threading versus multiprocessing in Python?

The short answer

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

Dimensionthreadingmultiprocessing
GILshared — serialises CPUseparate — true parallel
memoryshared address spacecopied / pickled
overheadlow (OS threads)higher (process fork + pickle)
best forI/O-boundCPU-bound
crash isolationnone — one thread can take down allstrong — process boundaries
Learn it properly Threading

Keep practising

All Python questions

Explore further

Skip to content