Skip to content
Path to Engineer
All lessons
PythonPython internals15 min read

What the Python GIL actually locks

The GIL explained properly: what it locks, why CPython has it, and the thing it does not prevent — which is where most race-condition bugs come from.

Day 65 of the 488-day pathway. Published in full — nothing held back.

THE GIL — what it actually locks, why it exists, what it does not prevent

The sentence to own: the GIL guarantees that only one thread executes Python bytecode at a time. It does not make your code thread-safe.

This question is guaranteed in a Python interview, and most candidates answer it wrong in one of two directions — either "Python can't do threads" (false; threads are excellent for I/O) or "the GIL protects my data" (false, and Day 067 proves it). Today you get the whole answer.


Part 1 — What it is

   THE GLOBAL INTERPRETER LOCK: a single mutex, per interpreter, that a thread must
    HOLD in order to execute Python bytecode.

    ⇒    one thread runs bytecode. The others are waiting for the lock.
    ⇒   it is a property of CPython —    not of the Python LANGUAGE.
         Jython and IronPython have no GIL. PyPy has one. So it is an implementation choice.
   WHY IT EXISTS — three reasons, and the first is the real one (Day 026):

  1.    REFERENCE COUNTING IS NOT ATOMIC.
       every object touch does ob_refcnt++ / ob_refcnt--. Two threads doing that
       simultaneously ⇒ a lost update ⇒    the count is too low ⇒ the object is FREED
       WHILE STILL IN USE ⇒ a use-after-free crash, or silent memory corruption.
  2.   C EXTENSIONS assumed it. Twenty years of libraries were written expecting
       that their state could not be touched concurrently.
  3.    SINGLE-THREADED SPEED. Making every refcount atomic costs roughly 2× on the
       common case — and most Python programs are single-threaded.

  ⇒    SO THE GIL IS A TRADE: one coarse lock is far cheaper than fine-grained locking
       everywhere, as long as you are not trying to run Python on many cores at once.

"Reference counting is not thread-safe, and making it atomic would cost about 2× on single-threaded code" is the sentence that distinguishes a real explanation from a memorised one. It also tells you why removing the GIL is hard rather than merely unpopular.


Part 2 — When it is released

   THE GIL IS RELEASED — this is why threads are not useless:

  1.    DURING BLOCKING I/O. Every socket read, file read, database call, subprocess wait.
       the thread releases the GIL, makes the syscall, and re-acquires when data arrives.
       ⇒    SO N THREADS CAN WAIT ON N SOCKETS SIMULTANEOUSLY. This is real concurrency.
  2.   time.sleep()
  3.    INSIDE C EXTENSIONS THAT RELEASE IT — Py_BEGIN_ALLOW_THREADS.
       ⇒    NumPy on large arrays, hashlib, zlib/bz2, Pillow, most database drivers
       ⇒    WHICH MEANS: numpy matrix work in threads DOES use multiple cores.
         That is a genuine exception to "threads give no parallelism", and worth knowing.
  4.   EVERY ~5 ms of pure Python — the switch interval, so no thread starves.
import sys
sys.getswitchinterval()          #   0.005 — seconds of bytecode before offering to yield
sys.setswitchinterval(0.001)     #   shorter = more responsive, more switching overhead

The switch interval is a request, not a preemption. The running thread checks a flag between bytecodes and drops the GIL if asked — so a single long-running C call that does not release the GIL blocks every other thread for its full duration, and there is nothing the interpreter can do.

   THE CONVOY EFFECT — the real production symptom, and it is a latency bug:
    one CPU-bound thread in a web worker holds the GIL in 5 ms bursts.
    ⇒    an I/O thread whose data has ARRIVED cannot resume until it gets the GIL back
    ⇒   so p99 latency rises sharply while average CPU looks fine
    ⇒    diagnosis: "one endpoint doing image resizing / JSON parsing made every OTHER
         endpoint slow." ⇒ move the CPU work to a process pool or a queue.

Part 3 — What it does NOT do

counter = 0
def worker():
    global counter
    for _ in range(100_000):
        counter += 1               #    NOT ATOMIC — three bytecodes (Day 067)

threads = [Thread(target=worker) for _ in range(10)]
[t.start() for t in threads]; [t.join() for t in threads]
print(counter)                     #    NOT 1,000,000. Something like 673,412.
   THE GIL DOES NOT MAKE YOUR CODE THREAD-SAFE, AND THIS IS THE MOST IMPORTANT
    MISCONCEPTION TO DESTROY:
    ·    it guarantees ONE BYTECODE AT A TIME — not one STATEMENT, not one OPERATION
    · `counter += 1` is LOAD, ADD, STORE ⇒ the GIL can be dropped between them
    · ⇒    read-modify-write races are fully possible. You still need locks. (Day 067.)

     WHAT IT DOES PROTECT: the INTERPRETER's OWN state — refcounts, the object allocator,
    internal structures. ⇒   your program will not segfault.    Your data can still be wrong.

"The GIL protects the interpreter, not your program" is the one-line version, and it is exactly what a good interviewer is listening for.

Claim True?
Only one thread runs Python bytecode at a time yes
Threads are useless in Python no — excellent for I/O
The GIL makes shared state safe no — Day 067
Threads can use multiple cores only inside C code that releases the GIL
multiprocessing avoids the GIL yes — one interpreter, one GIL, per process
The GIL is part of Python no — it is a CPython implementation detail

Part 4 — What is changing

   PEP 703 — THE FREE-THREADED BUILD (3.13 experimental, 3.14 officially supported):
    a separate build (`python3.13t`) with NO GIL. What replaced it:
    ·   BIASED REFERENCE COUNTING — the owning thread uses fast non-atomic counts;
         other threads use a slower atomic path. ⇒ the common case stays cheap.
    ·   DEFERRED refcounting for long-lived objects (modules, types, functions)
    ·   per-object locks on dict/list, and a thread-safe allocator (mimalloc)

     THE COSTS, AND THEY ARE REAL:
    ·   ~5–10% slower single-threaded (down from ~40% in early builds)
    ·    every C EXTENSION must be rebuilt and audited — a package that has not opted in
         makes the interpreter fall back to enabling the GIL
    ·    AND: your code now has REAL data races. The accidental safety some people relied
         on is gone. ⇒   locking discipline stops being optional.

The honest position for an interview: it is real, it is coming, and it does not change your design today — the free-threaded build is not the default, most of the ecosystem has not adopted it, and processes remain the answer for CPU-bound Python in production.

  PEP 684 — PER-INTERPRETER GIL (3.12): sub-interpreters each get their OWN GIL,
    so one process can use several cores — with isolated state, no shared objects,
    and cheaper startup than a process.   `concurrent.interpreters` in 3.14 makes it usable.
    ⇒   a middle ground between threads and processes. Worth naming; not yet worth adopting.

The GIL is not unique to Python — Ruby's MRI has one, and JavaScript avoids the question entirely by being single-threaded with an event loop (Day 014). The languages that avoided it mostly did so by not using reference counting.


Common mistakes

Mistake Correction
"The GIL makes my code thread-safe" It protects the interpreter. += still races.
"Threads are useless in Python" Released during I/O — excellent for waiting.
"The GIL is part of Python" CPython only. Jython/IronPython have none.
Not knowing it is released in C code NumPy in threads does use cores.
Threads for CPU work No parallelism. Processes (Day 068).
Blaming "Python is slow" for p99 spikes Often GIL convoy — one CPU thread starving I/O threads.
Expecting setswitchinterval to preempt It is a request checked between bytecodes.
Assuming free-threading solves everything Real data races appear; extensions must opt in.
Not knowing the reason it exists Non-atomic refcounts — the answer that matters.

Interview questions

Q: What is the GIL?

A single mutex per interpreter that a thread must hold to execute Python bytecode, so only one thread runs bytecode at a time. It exists primarily because reference counting isn't atomic — every object access increments or decrements a plain integer in the object header, and two threads doing that concurrently would lose updates and eventually free a live object. Making every refcount atomic costs roughly double on single-threaded code, so CPython took one coarse lock instead. It's an implementation detail of CPython, not part of the language: Jython and IronPython have no GIL.

Q: So are threads useless in Python?

No, and that's the other half people miss. The GIL is released during blocking I/O — every socket read, file read and database call drops it, makes the syscall and reacquires — so a hundred threads can wait on a hundred sockets simultaneously. It's also released inside C extensions that opt in, so NumPy on large arrays, hashlib and compression genuinely use multiple cores from threads. What threads can't do is run pure Python computation in parallel.

Q: Does the GIL make your code thread-safe?

No — and this is the misconception worth destroying. It guarantees one bytecode at a time, not one statement. counter += 1 compiles to load, add, store, and the GIL can be released between them, so ten threads incrementing a shared counter a hundred thousand times each reliably produces less than a million. The GIL protects the interpreter's own state — refcounts, the allocator — so you won't segfault. Your data can still be wrong, and you still need locks.

Q: You have a service where p99 latency spikes but average CPU looks fine. Could the GIL be involved?

Very possibly — that's the convoy effect. One CPU-bound thread holds the GIL in five-millisecond bursts, and an I/O thread whose data has already arrived can't resume until it gets the lock back. So unrelated endpoints get slower whenever the expensive one runs, and it presents as tail latency rather than as CPU saturation. The fix is to move the CPU work out of the worker — a process pool or a background queue — rather than to tune the switch interval.

Q: What's happening with the free-threaded build?

PEP 703 — a separate build with no GIL, experimental in 3.13 and officially supported in 3.14. It replaces the GIL with biased reference counting, where the owning thread uses a fast non-atomic path and other threads use an atomic one, plus deferred refcounting for long-lived objects and per-object locks on containers. The costs are real: five to ten percent slower single-threaded, and every C extension has to be rebuilt and audited — one that hasn't opted in makes the interpreter re-enable the GIL. And your code now has genuine data races, so locking discipline stops being optional. My honest position is that it's real and coming, and it doesn't change what I'd build today.


Mini task

  1. Run one CPU-bound function on 1, 2 and 4 threads and time each. Confirm it does not get faster — and note that it often gets slower from switching overhead.
  2. Run the same function on 1, 2 and 4 processes. Confirm it scales.
  3. Run 20 HTTP requests sequentially and on 20 threads. Record the speedup — that is the GIL being released during I/O.
  4. Do a large numpy matrix multiply on 4 threads and watch multiple cores light up. Explain why that contradicts nothing.
  5. Run the shared-counter race. Get a number below 1,000,000. Then run it again — a different number.
  6. Change sys.setswitchinterval to 0.000001 and re-run the counter. Watch the corruption get worse.
  7. Reproduce the convoy effect: a thread doing a tight loop alongside threads doing sleep, and measure the sleepers' wake-up latency with and without the CPU thread.
  8. Install a free-threaded build if you can and run task 1 again.

Exit questions

Answer aloud, no notes.

  1. What is the GIL, in one sentence?
  2. Give the three reasons it exists. Which is the real one?
  3. Why is removing it hard rather than just unpopular?
  4. Name four situations where it is released.
  5. Why do threads help I/O at all?
  6. Give the exception to "threads give no parallelism".
  7. Does the GIL make shared state safe? Justify with +=.
  8. What does it actually protect?
  9. What is the convoy effect, and how does it present in production?
  10. Is the GIL part of Python? Which implementations lack it?
  11. What replaces it in PEP 703, and what are the three costs?
  12. What are sub-interpreters, and where do they sit?

Articulation drill

Record two minutes: "Explain the GIL." (Assume this is asked. Practise it until it is fluent.)

What it is: one mutex per interpreter; a thread must hold it to run bytecode, so only one runs Python at a time.

Why — and lead with the real reason, not the folklore: "reference counting isn't atomic. Every object access touches a plain integer in the header, so with true parallelism two threads lose an update, the count goes too low, and a live object gets freed — a use-after-free. Making every refcount atomic costs about double on single-threaded code, and most Python programs are single-threaded, so CPython took one coarse lock instead."

Then correct both misconceptions, unprompted, because that is where the marks are: "two things people get wrong. It doesn't make threads useless — it's released during every blocking I/O call and inside C extensions that opt in, so threads are genuinely good for waiting, and NumPy work in threads really does use multiple cores. And it doesn't make your code thread-safe: it guarantees one bytecode, not one statement, so counter += 1 — load, add, store — still races. It protects the interpreter, not your program."

Close on the practical consequence: "so CPU-bound Python needs processes, I/O-bound needs threads or an event loop, and free-threading in 3.14 is real but doesn't change that design today."


Previous: Day 064 · Tomorrow: Day 066 — threading: when it genuinely helps, Lock, RLock, Event, and queue.Queue