Runtimepythoncpythongilasynciomultiprocessing

CPython Performance: The Interpreter Tax and the GIL

CPython pays a per-operation interpreter cost that no algorithm change removes, and its global lock means CPU-bound threads do not run in parallel. Neither fact makes Python slow at the thing most services actually do, which is wait.

Follow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
My Python service is CPU-bound and adding threads did nothing — what is actually limiting it?
Symptom
CPU pinned at roughly one core's worth regardless of how many worker threads are configured. Throughput flat. Adding threads increases memory and context switching and nothing else.
Signal
Process CPU capped near 100% of a *single* core while multiple threads are runnable is the confirmation. Aggregate CPU percentage on a multi-core box misleads badly — 100% of one core on an 8-core machine reads as 12.5%.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

The interpreter tax

Runtime-specific · CPython (the reference implementation). PyPy, GraalPy and other implementations have substantially different cost profiles.

CPython executes bytecode in an interpreter loop: fetch an instruction, dispatch on its opcode, manipulate reference-counted objects on the heap. Every arithmetic operation on integers allocates or reuses a Python object; every attribute access is a dictionary lookup with a cache in front of it. The result is a roughly constant multiplier on the cost of *interpreted* work compared to compiled code, and no amount of algorithmic cleverness removes the multiplier itself.

What this means practically is that the shape of the optimization changes. In a compiled language the question is often "which algorithm?"; in CPython the first question is usually "how do I do this work somewhere other than the interpreter loop?" — vectorized library calls, native extensions, database-side computation, or a different process entirely. A tight Python loop over a million rows and a single SQL aggregate over the same rows differ by orders of magnitude for reasons that have nothing to do with big-O.

It also means the multiplier only matters where interpreted work dominates. A service whose request spends 2ms in Python and 40ms waiting on a database is not meaningfully limited by the interpreter, and rewriting it in a faster language would improve total latency by a few percent. Computing or Waiting? decides whether any of this section applies to you at all — measure before assuming.

Where the work can happen, and what that costs
Move the work to…MechanismWinsCosts
A native libraryNumPy, Polars, orjson, regex enginesThe loop runs in compiled code; often orders of magnitudeData must fit the library's model; a dependency and its build story
The databaseAggregate, filter and join in SQL rather than in PythonNo row transfer, no per-row object constructionMoves load onto the database, which has its own limits
Another processmultiprocessing, a worker fleet, a separate serviceTrue parallelism across cores, sidestepping the GILIPC serialization, memory per process, more deployment surface
A native extensionCython, Rust via PyO3, CRemoves the interpreter tax on the hot path specificallyBuild toolchain, a second language, harder debugging
Nowhere — keep it in PythonAccept the costSimplicity; correct choice when the path is not hotNone, if the measurement says the path is not hot

What the GIL does and does not block

Runtime-specific · CPython. The GIL is being made optional (free-threaded builds, PEP 703) and behaviour is version-dependent — verify against your interpreter.

The Global Interpreter Lock allows only one thread to execute Python bytecode at a time within a process. The consequence people remember is that CPU-bound threads do not run in parallel: four threads each computing hashes finish in roughly the time one thread would take for all four, plus context-switching overhead. Adding threads to a CPU-bound Python workload is close to a no-op, and occasionally a small negative.

The consequence people forget is that the GIL is *released* around blocking I/O and inside many native extensions. A thread waiting on a socket, a file, or a database driver is not holding the lock, so it does not block other threads. This is why thread pools work perfectly well for I/O-bound Python services, and why the blanket claim "Python cannot do concurrency" is wrong — it can do exactly the kind most web services need.

That splits the guidance cleanly. CPU-bound: use processes, native extensions, or move the work elsewhere. I/O-bound: threads are fine, and asyncio is often better still because it avoids per-thread stack memory and context-switch cost at high connection counts. The mistake worth avoiding is reaching for asyncio to fix a CPU-bound problem — an async event loop has exactly the Event-Loop Lag: One Callback, Everybody Waits failure mode, and a CPU-heavy coroutine blocks every other task in the loop.

Threads for CPU-bound work — the GIL serializes them
1from concurrent.futures import ThreadPoolExecutor
2
3def score(rows): # pure Python: holds the GIL throughout
4 return sum(expensive_score(r) for r in rows)
5
6# 8 threads, 8 cores, and roughly single-core throughput:
7with ThreadPoolExecutor(max_workers=8) as pool:
8 results = list(pool.map(score, chunks))
9
10# Observed: process CPU ~100% of ONE core.
11# On an 8-core box the dashboard says 12.5% and looks idle.
12# Adding workers adds memory and context switches, not throughput.
Processes for CPU-bound, threads kept for I/O
1from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
2
3# CPU-bound: separate processes, separate interpreters, real parallelism
4with ProcessPoolExecutor(max_workers=8) as pool:
5 results = list(pool.map(score, chunks))
6 # cost: chunks are pickled across the boundary; keep them coarse
7
8# I/O-bound: threads are fine — the GIL is released while waiting
9with ThreadPoolExecutor(max_workers=64) as pool:
10 responses = list(pool.map(fetch_url, urls))
11
12# Better still for very high I/O concurrency: asyncio,
13# which avoids per-thread stacks — but only for I/O, never for CPU.

The distinction is not "threads bad, processes good" — it is which resource the work is waiting on. Threads are the right tool for I/O-bound Python and the wrong tool for CPU-bound Python, and telling the two apart is a measurement, not a preference.

Diagnosing it from the outside

Runtime-specific · CPython

The single most useful reading is process CPU expressed per-core rather than as a fraction of the machine. A CPU-bound CPython process pinned by the GIL sits at approximately 100% of one core and will not exceed it no matter how many threads are configured. On an 8-core instance that is 12.5% aggregate — a number that looks like abundant headroom and is in fact complete saturation of the only resource that matters.

A sampling profiler that can attach to a running process (py-spy and similar) is the second reading, and it distinguishes the two cases immediately: time concentrated in Python frames means interpreted work is the constraint, while time in select, socket reads or driver internals means the process is waiting and the GIL is irrelevant to your problem.

Then confirm with an intervention rather than an argument: run the same workload with a process pool instead of a thread pool. If throughput scales roughly with process count, it was GIL-bound. If it does not, the constraint is elsewhere — and you have learned that before rewriting anything.

A CPU-bound CPython worker on an 8-core instanceILLUSTRATIVE
SignalValueWhat it tells youVerdict
aggregate CPU12.4%Reads as idle. This is the number that sends teams looking at the database instead.normal
CPU as cores0.99 coresPinned at exactly one core — the GIL signature.smoking gun
runnable threads8Eight threads ready to run, one permitted to execute bytecode.smoking gun
throughput vs 1 thread1.03xEight times the threads, three percent more work done.smoking gun
py-spy top frameexpensive_score (pure Python)Interpreted work, not waiting — so the GIL is genuinely the constraint here.smoking gun
context switches+340%The threads are fighting over the lock, adding overhead for no throughput.suspect

Key points

  • CPython pays a per-operation interpreter cost that algorithm changes do not remove — the fix is usually to move hot work out of the interpreter.
  • The GIL serializes bytecode execution, so CPU-bound threads do not run in parallel; process count, not thread count, is the lever.
  • The GIL is released around blocking I/O, so thread pools work well for I/O-bound services — the blanket "Python cannot do concurrency" claim is wrong.
  • Aggregate CPU percentage hides the failure: 100% of one core on an 8-core box reads as 12.5% and looks like headroom.
  • asyncio fixes I/O concurrency, not CPU-bound work — a CPU-heavy coroutine blocks the loop exactly like any other event loop.

Follow the diagnosis

The causal chain, hop by hop — and the readings that invite the wrong conclusion.

  1. 1
    Request → handler: each job scores several thousand rows in pure Python, so the work is interpreted bytecode.
  2. 2
    Handler → GIL: the scoring loop holds the lock continuously; other threads become runnable but cannot execute bytecode.
  3. 3
    GIL → CPU: the process saturates one core and cannot exceed it, so aggregate CPU on an 8-core instance sits near 12%.
  4. 4
    Thread pool → throughput: raising workers from 1 to 8 increases throughput by ~3% and context switches by ~340%.
  5. 5
    Dashboard → responders: the low aggregate CPU reading redirects the investigation to the database, which has ample headroom and is not involved.
What this evidence makes people conclude — wrongly
  • "CPU is 12%, so we are not CPU-bound" — express it in cores; one pinned core is total saturation of the only core that can run bytecode.
  • "Adding threads did not help, so the code is I/O-bound" — for CPU-bound Python, threads not helping is the expected GIL behaviour.
  • "Python is slow, we should rewrite the service" — measure the interpreted fraction first; a service that spends 95% of a request waiting will barely improve.
  • "Use asyncio to go faster" — asyncio addresses I/O concurrency; CPU-heavy coroutines block the loop and make things worse.
  • "The GIL means Python cannot handle concurrent requests" — it is released around I/O, which is what most web services spend their time doing.

Measure, fix, validate

An optimization is not finished until the metric that motivated it has moved.

How to measure it
  • • Process CPU expressed in cores (not as a machine percentage), where a pin at ~1.0 core with many runnable threads is the GIL signature.
  • • A sampling profiler attached to the live process, to see whether top frames are Python bytecode or blocking calls.
  • • Throughput as a function of worker count, run once with threads and once with processes — the scaling shape is the diagnosis.
  • • Context-switch rate, which rises sharply when threads contend for the lock without gaining throughput.
  • • Time split between interpreted work and waiting, which decides whether any interpreter-level optimization is worth doing at all.
What actually fixes it
  • • Determine the interpreted fraction first with a sampling profiler; if the process is mostly waiting, none of the GIL work applies.
  • • For CPU-bound work, use process-level parallelism (`multiprocessing`, a worker fleet) so each interpreter has its own lock.
  • • Move hot loops into native libraries or push aggregation into the database, removing the interpreter tax rather than parallelizing it.
  • • For I/O-bound work, keep threads or move to `asyncio` at high connection counts, where per-thread stacks become the constraint.
  • • Write a native extension for a genuinely hot, genuinely interpreted path — last, because it costs a build toolchain and a second language.
How you know it worked
  • • Throughput scales approximately with process count after the change; if it does not, the constraint was never the GIL.
  • • Process CPU rises above one core (or aggregate CPU rises proportionally to worker processes), confirming real parallelism.
  • • Context-switch rate falls, since threads are no longer contending for a lock they cannot share.
  • • End-to-end latency improves for the affected endpoint specifically — a throughput win that leaves latency unchanged means the bottleneck moved.
What it costs
  • • Process pools multiply memory footprint — each process is a full interpreter with its own heap — and pay serialization on every boundary crossing.
  • • Pushing computation into the database moves load onto a shared resource that is usually harder to scale than application instances.
  • • Native extensions remove the interpreter tax and add a build toolchain, a debugging story and a portability burden.
  • • `asyncio` changes the concurrency model of the whole codebase; partial adoption produces subtly blocking code that is harder to diagnose than threads.
Stop it coming back
  • CPU tracked in cores per process on the default dashboard, so a GIL pin is visible rather than disguised as low utilization.
  • A load test asserting throughput scaling with worker count, which fails if someone reintroduces CPU-bound work into a thread pool.
  • A review rule that CPU-heavy functions in async or threaded contexts require explicit justification, since both hide the problem the same way.
  • Interpreter and dependency versions pinned and recorded, because GIL behaviour and free-threaded builds are version-dependent.

Accuracy

Performance numbers are conditional. These are the conditions.

What these numbers depend on
  • RUNTIME-SPECIFICThis describes CPython, the reference implementation. PyPy, GraalPy and others differ substantially. The GIL is also being made optional via free-threaded builds (PEP 703), so behaviour depends on your interpreter version and build — verify rather than assume.
  • ILLUSTRATIVEThe 12.4% aggregate CPU and 1.03x scaling figures are constructed to show the shape of GIL saturation. The relationship (one pinned core regardless of thread count) is the transferable part.

Misconceptions

Claim
“The GIL means Python cannot do concurrency.”
Reality
It is released around blocking I/O and inside many native extensions, so thread pools handle I/O-bound workloads — which is most of what web services do — perfectly well. It constrains CPU-bound parallelism specifically.
Claim
“Low aggregate CPU means there is CPU headroom.”
Reality
A GIL-pinned CPython process saturates exactly one core. On an 8-core instance that is 12.5% aggregate, which reads as idle while the process is completely saturated on the only resource that matters.
Claim
“asyncio makes Python faster.”
Reality
It makes I/O concurrency cheaper at high connection counts. It does nothing for CPU-bound work, and a CPU-heavy coroutine blocks every other task on the loop — the same failure mode as any single-threaded event loop.

Apply it