The GIL, and free-threaded Python

Updated 5 interview angles 7 min read source
On this page10
  1. What the GIL is, and why it exists
  2. How threads take turns
  3. What the GIL never protected
  4. The free-threaded build
  5. Four concurrency models
  6. Choosing, in
  7. Where it breaks in production
  8. The trade not taken
  9. Related
  10. Interview angle

The GIL, and free-threaded Python

Article.

The most-changed Python interview topic in years. The GIL still exists in the default build, but since Python 3.14 the free-threaded build is officially supported (PEP 779), and answering as if the GIL were permanent dates you. What does the GIL actually guarantee, what did it never guarantee, and what changes when it is gone?

Numbers below are from CPython 3.14.5, with and without the GIL, using 4 threads on a 12-thread Windows machine.

What the GIL is, and why it exists

A per-interpreter lock: only the thread holding it may execute Python bytecode. It exists because CPython manages memory by reference counting, and reference counts change on almost every operation. Making each increment atomic would slow every program; one coarse lock was cheaper (Python memory model — allocation, refcounting, GC).

Workload Threads help? Why
CPU-bound Python code no one thread runs bytecode at a time
blocking I/O yes the GIL is released while a thread waits
heavy C extensions often NumPy and friends release it around native loops

How threads take turns

Since Python 3.2 a waiting thread does not spin. It waits up to the switch interval, sys.getswitchinterval(), 5 ms by default, then sets a “drop request” flag. The running thread checks that flag at safe points of the interpreter loop (function calls, backward jumps), releases the GIL, and the waiting thread takes it.

C code that blocks or computes for long releases the GIL explicitly (Py_BEGIN_ALLOW_THREADS around read(), sleep(), a NumPy kernel), which is why I/O threads overlap.

The convoy effect

An I/O-bound thread next to a CPU-bound one suffers. Every time the I/O thread’s call returns, it must take the GIL back from the CPU thread, which can mean waiting a full switch interval, so a thread that does thousands of short I/O operations a second slows to a crawl.

Lowering sys.setswitchinterval() shortens the waits at the cost of more switching; the real fixes are moving the CPU work to a process or a free-threaded build, or the I/O to asyncio.

What the GIL never protected

python
counter = 0

def bump(n):
    global counter
    for _ in range(n):
        counter += 1          # read, add, write: three steps

# two threads, a million each
Build Result of 2 threads x 1,000,000
GIL build, 3.14.5 2,000,000
free-threaded, 3.14.5t about 1,011,000

On the GIL build the count came out exact, and stayed exact with a function call inside the update. That is not correctness: the GIL can switch threads between the read and the write, it just rarely does at that exact point with a 5 ms interval. The free-threaded build runs both threads at once and loses half the updates.

The GIL made races rare, never impossible; code that shares mutable state needs a Lock on either build (Synchronization primitives).

The free-threaded build

bash
python3.14t -c "import sys; print(sys._is_gil_enabled())"    # False

It is a separate build of CPython (wheels are tagged cp314t), opt-in, and not the default; no date has been set for making it one. What it changes:

3.14.5, 4 threads GIL build Free-threaded
4 CPU-bound threads 1.0x throughput 3.5x throughput
one task alone 207 ms 240 ms

CPU-bound threads finally scale across cores. The price is single-threaded speed: about 16% in this tight loop, while the 3.14 release notes put the typical cost at 5-10% (3.13t was much slower, because the specialising interpreter was disabled there).

How it works without the GIL

  • Biased reference counting. Each object has a cheap non-atomic count for the thread that created it and an atomic count for every other thread.
  • Immortal and deferred objects. Immortal objects (PEP 683) are never counted; for heavily shared ones such as functions and modules, counting is deferred to the garbage collector.
  • Per-object locks. list and dict operations take a lightweight lock on the object (a “critical section”), so built-in containers stay internally consistent. Your read-modify-write sequences are not.
  • mimalloc, a thread-safe allocator, replaces pymalloc, and memory is reclaimed only once no thread can still be reading it.

What else changes

  • Extensions can switch the GIL back on. An extension module that does not declare free-threading support (the Py_mod_gil slot set to Py_MOD_GIL_NOT_USED) re-enables the GIL when imported, with a RuntimeWarning. Check sys._is_gil_enabled() after imports; force it off with PYTHON_GIL=0 or -X gil=0 only after testing that extension.
  • Threads inherit context variables. On 3.14t sys.flags.thread_inherit_context is 1 (it is 0 on the GIL build): a new thread starts with a copy of the caller’s contextvars. Request IDs and logging context now follow work into threads, which helps, and surprises code that relied on a clean slate.

Four concurrency models

Model Parallel Python bytecode Memory
asyncio no shared, one thread
threads, GIL build no shared
threads, free-threaded yes shared
subinterpreters (3.14) yes, one GIL each isolated
processes yes isolated, data pickled
python
from concurrent.futures import InterpreterPoolExecutor      # 3.14

with InterpreterPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(score, chunks))     # args and results are pickled

Subinterpreters (PEP 734, concurrent.interpreters in 3.14) run in one process, each with its own GIL. Creating one and running a first statement took 21 ms here, against 254 ms to spawn a process on Windows. The docs list the current limits: start-up is not yet optimised, each uses more memory than it needs to, and many extension modules on PyPI do not support them yet.

Choosing, in 2026

  • I/O-bound with async libraries → asyncio, still the default for network services.
  • I/O-bound with blocking libraries → threads, on either build.
  • CPU-bound in NumPy, Polars or PyTorch → often already parallel inside the library; measure first.
  • CPU-bound pure Python → processes are the safe default. The free-threaded build with threads is the direction of travel where your dependencies support it and your code is audited for races; subinterpreters fit shared-nothing work in between.

Where it breaks in production

Races that were always there

Moving to 3.14t turns rare races into frequent ones: counters that drift, check-then-act on a dict, lazily initialised singletons built twice. They were bugs on the GIL build too. Run the test suite under 3.14t with many threads before trusting it; lock shared state.

The GIL quietly comes back

A single dependency without free-threading support re-enables the GIL for the whole process, and the deployment shows no speed-up. Check after the imports, at start-up:

python
import sys, sysconfig

if sysconfig.get_config_var("Py_GIL_DISABLED"):    # a free-threaded build
    assert not sys._is_gil_enabled(), "an extension re-enabled the GIL"

The RuntimeWarning printed at import names the module responsible.

Latency from GIL contention

A service mixing CPU-heavy work (JSON of large payloads, template rendering) with I/O threads shows high latency the CPU graphs do not explain: the convoy effect. py-spy dump or py-spy top --gil shows which threads hold the GIL.

The trade not taken

Removing the GIL by making every reference count atomic was tried (the “Gilectomy”) and made single-threaded code far slower. PEP 703’s biased reference counting keeps the common case cheap, which is why free-threading was accepted. The conservative alternative remains: keep the GIL and use processes or subinterpreters for parallel CPU work, isolating state instead of locking it. For most services that is still the lower-risk choice in 2026.

Interview angle 5

  • “What is the GIL and why does it exist?” — a per-interpreter lock that lets one thread run Python bytecode at a time, because reference counts change constantly and one coarse lock was cheaper than atomic counts.
    • Follow-up: “When exactly does a thread give up the GIL?” — at blocking calls that release it, or when another thread has waited the 5 ms switch interval and set a drop request, honoured at the next safe point in the interpreter loop.
    • Follow-up: “Why does an I/O thread slow down next to a CPU-bound one?” — the convoy effect: each time it returns from I/O it may wait a full switch interval to get the GIL back.
  • “Two threads each run counter += 1 a million times. What is the result?” — not guaranteed. On 3.14.5 it came out at exactly 2,000,000 on the GIL build and about 1,011,000 on the free-threaded one.
    • Follow-up: “It was exact on the GIL build. Is the code correct?” — no: the GIL can switch between the read and the write; it rarely happens to. Use a lock.
  • “Is the GIL still a thing in 2026?” — in the default build, yes. The free-threaded build is officially supported since 3.14 (PEP 779) but opt-in, with a single-threaded cost of roughly 5-10% and real scaling: 3.5x for four CPU-bound threads here.
  • “You moved to 3.14t and got no speed-up. What do you check?” — whether the GIL came back: sys._is_gil_enabled(), and the RuntimeWarning from an extension that does not declare support. Then whether the work is actually CPU-bound Python.
  • “CPU-bound work in Python today: what do you reach for?” — check whether the library already parallelises; otherwise processes, with free-threaded threads or subinterpreters where dependencies allow.