The GIL is not why your Python is slow

> $ stat metadata
Date: 2026.09.25
Time: 5 min read
Tags: [python, gil, concurrency, performance, profiling, multiprocessing]

A Python API is slow. p99 sits at 900 milliseconds and the team has decided the interpreter lock is the reason. The plan on the whiteboard is a rewrite in Go.

Somebody profiles it first. 780 of those 900 milliseconds are spent waiting on the database, across 43 separate queries issued in a loop. Another 60 milliseconds is JSON serialisation of a response containing fields nobody reads. Actual Python bytecode execution accounts for something like 40 milliseconds.

The lock was contended for a fraction of a fraction of the request. Rewriting in Go would have produced a service that issues 43 sequential queries very efficiently.

What the lock actually holds

The global interpreter lock guarantees that only one thread executes Python bytecode at a time inside a process. That is the whole of it, and the important part is the word bytecode.

The lock is released whenever the interpreter is doing something that is not executing your Python code:

Socket operations release it, so a thread waiting on a database response is not holding it.

File I/O releases it.

Most C extensions release it around their heavy work. NumPy releases it during array operations. hashlib, zlib and the compression libraries release it. lxml releases it during parsing.

Long running pure Python code releases it periodically, every 5 milliseconds by default under sys.setswitchinterval, so one thread cannot monopolise the interpreter indefinitely.

flowchart TB
    R["Request handler"] --> A["Build query<br/>holds GIL, ~1ms"]
    A --> B["DB round trip<br/>GIL RELEASED, 18ms"]
    B --> C["Map rows to objects<br/>holds GIL, ~3ms"]
    C --> D["Call another service<br/>GIL RELEASED, 40ms"]
    D --> E["Serialise JSON<br/>mostly C, largely released, ~8ms"]

    style B stroke:#4ade80,stroke-width:3px,color:#fff
    style D stroke:#4ade80,stroke-width:3px,color:#fff

Out of that request, the lock is held for a handful of milliseconds. A threaded server running this handler across 50 threads gets genuine concurrency, because at any instant most of those threads are parked in a socket read with the lock released.

What is actually slow

Every time I have looked at a slow Python service, the answer has been in one of three places and none of them was the lock.

Database round trips dominate, and usually because of a loop. An ORM lazily loading a relation inside an iteration issues one query per item, and that pattern is invisible to every database side metric because no individual query is slow. Forty three queries at 18 milliseconds each is 780 milliseconds regardless of what language issued them.

Unbatched external calls have the same shape. Ten sequential HTTP calls to an internal service, each 40 milliseconds, is 400 milliseconds of a request that could have been 40 with asyncio.gather or a batch endpoint.

Serialisation is the genuine CPU cost in most Python services, and it scales with how much you return rather than how much you need. Returning a full object graph where the client uses four fields means allocating and serialising the rest for nothing. Switching to orjson is often a real improvement, and returning less is usually a bigger one.

import cProfile, pstats

profiler = cProfile.Profile()
profiler.enable()
handle_request(sample_payload)
profiler.disable()

pstats.Stats(profiler).sort_stats("cumulative").print_stats(25)

Sorting by cumulative time answers the question directly. If the top entries are socket reads, the lock is irrelevant. If they are json.dumps and dictionary construction, you have a serialisation problem. If they are your own functions doing arithmetic, then and only then is the lock worth thinking about.

For a running production process, py-spy samples without modifying or restarting it, which makes it the tool I would reach for first when the slowness only happens under real traffic.

Where the lock genuinely bites

There is a real case and it is narrower than the reputation suggests: sustained computation in pure Python across multiple threads.

Image resizing implemented in Python rather than in Pillow’s C paths. A scoring loop over a large list. Parsing a big structure with hand written Python. Run four threads of that and you get roughly the throughput of one, plus the overhead of them fighting over the lock.

The fix is processes.

from concurrent.futures import ProcessPoolExecutor

# Each process has its own interpreter and its own lock,
# so this genuinely uses four cores.
with ProcessPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(score_document, documents))

The cost is that arguments and results are pickled and copied across a process boundary, so this pays off when the computation is large relative to the payload. For a large array, shared memory avoids the copy. For a very large payload with small computation, the copying can cost more than the parallelism gains.

Most production deployments already do this without calling it multiprocessing. Gunicorn with four workers is four processes, four interpreters, four locks, four cores. The lock has never been the constraint on a service deployed that way, because the concurrency lives above the process rather than inside it.

The changes coming

Python 3.12 added per-interpreter locks, so subinterpreters within one process can execute in parallel. It is a foundation rather than something most applications use directly yet.

Python 3.13 shipped an experimental free-threaded build with the lock removed entirely, available as a separate binary. Single threaded code is measurably slower on it, because the reference counting that the lock made cheap now needs its own synchronisation, and C extensions have to be rebuilt and verified for thread safety.

I find the direction genuinely interesting and I would not plan a service around it yet. The ecosystem question is the binding one: a service is only as free-threaded as its least prepared dependency.

Before you rewrite

Profile first, and specifically look at where cumulative time goes rather than at which function is called most.

If the top of the profile is I/O, the language is not the problem. Count round trips, batch them, and check for a loop issuing queries.

If it is serialisation, return less and use a faster encoder.

If it is genuinely your own computation across threads, use processes, and consider whether the hot loop belongs in a library that has already been written in C.

The reason I push back on the lock as an explanation is that it is unusually satisfying: it is external, it is well known, and it absolves the code. That combination makes it the first thing people reach for and the last thing the profile supports.

Frequently Asked Questions

What does the Python GIL actually prevent?
It prevents more than one thread from executing Python bytecode at the same time within a single interpreter process. It does not prevent threads from running concurrently during operations that release it, which includes file and socket I/O, most work inside C extensions such as NumPy and compression libraries, and calls into the operating system. A threaded Python service handling network requests achieves real concurrency because nearly all of its time is spent in code that has released the lock.
Is Python slow because of the GIL?
Rarely, for typical services. A web service spends most of its time waiting on databases and other services, and the lock is released during that waiting. The common causes of slowness are excessive database round trips, unbatched network calls, and CPU spent on JSON serialisation and object allocation. The lock becomes the real limit only for sustained multi-threaded computation in pure Python, and the standard answer there is multiprocessing, which sidesteps it entirely.
How do you use multiple CPU cores in Python?
Run multiple processes rather than multiple threads, since each process has its own interpreter and its own lock. The multiprocessing module and ProcessPoolExecutor do this within one program, and most production web deployments do it at the server level by running several worker processes behind Gunicorn or Uvicorn. Python 3.13 also introduced an experimental free-threaded build that removes the lock entirely, and Python 3.12 added per-interpreter locks for subinterpreters, though neither is the default.

[ RELATED_LOGS ]

TTFB: -- ms LOAD: -- s PAYLOAD: -- kb