Skip to content
← All articles

What the GIL Actually Guarantees

Thread switches happen between bytecodes — that is the entire guarantee. Everything else people believe about the GIL is a consequence of it, or is false.

Two beliefs about the GIL are common, and both of them cost money.

The first is “the GIL makes Python thread-safe.” Teams holding it ship a shared counter with no lock, a dedup set mutated from four request handlers, an LRU cache whose eviction is a read-modify-write. Under light load it works. Under load it produces a wrong number roughly once a week, and the bug is never reproducible in staging.

The second is “the GIL means threads are useless.” Teams holding it reach for multiprocessing on a workload that spends 95% of its time waiting on a socket, and pay a fork per worker, a pickle per message and 10x the resident memory for a speedup of zero.

Both come from not knowing what the guarantee actually is. It is small, it is precise, and it is worth being able to state in one sentence.

The guarantee

The CPython interpreter switches threads between bytecode instructions, not in the middle of one. So each individual bytecode executes atomically from the point of view of Python code, and so does each call into C that does not itself release the lock.

That is the whole thing. Everything people believe about the GIL is a consequence of that sentence, or is false.

What follows immediately:

  • A single bytecode is indivisible. LIST_APPEND, STORE_SUBSCR on a dict, a list.append that bottoms out in one C call — no other thread observes a half-finished state.
  • A statement is not. self.n += 1 compiles to a load, an add and a store. Three instructions, two switch points, and the classic lost update.
  • A method call you wrote is not. Anything with a Python frame in it has as many switch points as it has bytecodes.

What the interpreter does not promise

The GIL is not a scheduler. From the sys documentation: the interpreter does not have its own thread scheduler; which thread runs next is the operating system’s decision. sys.setswitchinterval() sets how long the running thread holds the GIL before being asked to drop it — the default is 5 milliseconds — but “asked to drop it” is not “the thread you want runs next”. You cannot reason about ordering, fairness, or starvation from Python.

Two more things the GIL does not do:

  • It is not held across blocking I/O. A thread in socket.recv, file.read, time.sleep or subprocess.wait has released it. This is why threads are a perfectly good answer for I/O-bound work.
  • It is not held by every C extension. Extensions that do long computations are expected to release it around them, and the ones that matter do: NumPy’s heavy kernels, zlib, hashlib, Pillow’s codecs, compiled ML kernels. CPU-bound code inside such a library is already parallel across threads.
💡A service handler does results = [r for r in db.query(sql)] and, in parallel, another handler does the same. Both are CPU-light and I/O-heavy. Someone proposes moving to multiprocessing "because of the GIL". What is wrong with the proposal, and what measurement settles it? click to reveal

Nothing about that workload is GIL-bound. The time is spent inside the database driver’s socket read, and the GIL is released for the whole of it — so N threads genuinely wait on N sockets simultaneously. Processes would buy you nothing except a fork, a pickle round trip per row batch, and N copies of the interpreter and of every imported module.

The measurement that settles it is the ratio of CPU time to wall time for the process: time python app.py and compare user + sys against real. If user time is a small fraction of wall time, you are waiting, not computing, and the GIL is not your constraint. py-spy dump on a live process answers the same question qualitatively — if every thread’s top frame is in a recv, there is no contention to remove.

The genuinely GIL-bound case is: CPU time approaches wall time, the hot frames are pure Python, and adding threads does not reduce wall time. That is when the answer is processes, subinterpreters, or a free-threaded build.

Why the atomic list is a trap

The FAQ publishes a list of operations that happen to be atomic in CPython today — L.append(x), D[x] = y, L1.extend(L2) — and an explicit list of operations that are not — i = i + 1, L.append(L[-1]), D[x] = D[x] + 1.

Do not memorise it. Three reasons.

It describes an implementation, not the language. It is true of CPython’s current compiler and object layout. Nothing in the language reference promises it, and the free-threaded build declines to guarantee it.

It is fragile under edits that look innocent. D[x] = y is atomic when x and y are already-evaluated locals. Make x a call and you have introduced switch points into the same line. Give the container a __setitem__ written in Python — a defaultdict with a Python __missing__, a custom mapping, a Counter subclass — and there is a whole Python frame inside the “atomic” operation.

Atomicity of a single operation is almost never the invariant you need. The thing you actually want is “no two threads both observe capacity as available”, which spans a read and a write no matter how atomic either is individually.

The FAQ’s own conclusion is the one to keep: when in doubt, use a mutex. A threading.Lock acquire/release is tens of nanoseconds uncontended. It is never the reason your service is slow, and it is the only construct whose correctness does not depend on a CPython implementation detail.

💡counts[key] += 1 where counts is a collections.Counter shared by four threads. Is it safe? What about counts.update([key])? click to reveal

Neither is safe, and for different reasons worth separating.

counts[key] += 1 is the FAQ’s own non-atomic example. It is a __getitem__, an add, and a __setitem__ — and on a Counter the __getitem__ path for a missing key runs __missing__, which is Python code. Two threads can both read 7 and both write 8.

counts.update([key]) looks better because it is one method call, and on a plain dict update from another dict is atomic. But Counter.update is implemented in Python — it loops, reading and writing each key — so it has frames, and therefore switch points, all the way through. The single-call shape tells you nothing.

The fix is boring and correct: a Lock around the mutation, or — better where it fits — give each thread its own Counter and merge them once at the end with +. No shared mutable state means no lock and no contention, which is also considerably faster than a hot lock.

What this buys you in practice

Three rules follow, and they cover most real code.

  1. Shared mutable state gets a lock, always. Not “if the operation looks atomic”. The cost of the lock is not the thing that will show up in your flame graph.
  2. Prefer not sharing. Per-thread accumulators merged at the end, or a queue.Queue (which owns its own locking) as the only channel between threads, removes the question rather than answering it.
  3. Diagnose before you change the model. CPU-time-over-wall-time tells you whether the GIL is even in the picture. If it is not, threads are the right tool and you have a different problem.

Track 10 takes up what happens when the GIL genuinely is the constraint — processes, subinterpreters and free-threaded builds. This track is about being correct first, because a concurrency bug that only appears under load is the most expensive kind of bug there is.