Free lesson · Concurrency Interview Mastery
The Global Interpreter Lock (GIL)
If there's one Python concurrency concept you must understand cold, it's the GIL. It's the reason Python threading behaves unlike threading in Java, C++, or Go, and it's the single most common Python concurrency interview question. Get this lesson right and most of Python's concurrency design - why threading is for I/O, why multiprocessing exists, why asyncio is popular - suddenly makes sense as a coherent response to one underlying constraint.
Let me state it plainly and then unpack every word.
The GIL is a single mutex in CPython that allows only one thread to execute Python bytecode at a time. Even with many threads on a multi-core machine, only one is running Python code at any given instant.
What the GIL actually does
Imagine a single "permission to run Python" token. To execute Python bytecode, a thread must hold that token. There's exactly one. So no matter how many threads you create or how many cores your machine has, only the thread holding the GIL is executing Python at any moment. The others are waiting for the token.
This means Python threads do not run Python code in parallel. They run concurrently - interleaved, taking turns holding the GIL - but never simultaneously on multiple cores. It's the single-core interleaving model from Module 1, enforced at the interpreter level even when you have 32 cores sitting idle.
flowchart TD
G[("The GIL - one token")]
T1[Thread 1] -->|holds GIL: runs Python| G
T2[Thread 2] -.->|waiting for GIL| G
T3[Thread 3] -.->|waiting for GIL| G
G -->|released after ~5ms or on I/O| Next[Another thread acquires it]
Next -.-> T2
This diagram shows the Global Interpreter Lock as a single token. Three threads exist, but only thread one holds the GIL and is therefore running Python code. Threads two and three are waiting for the token. The GIL is released after a short interval, roughly five milliseconds, or when the running thread hits a blocking input-output operation. When released, another waiting thread - say thread two - acquires the token and gets its turn to run Python. Only one thread ever runs Python bytecode at a time.
The GIL is released periodically (CPython switches threads roughly every 5 milliseconds for CPU-bound code) and - crucially - it's released whenever a thread does blocking I/O. That release-on-I/O is the loophole that makes Python threading useful at all, as we'll see.
Why does the GIL exist?
People love to call the GIL a flaw, but it was a reasonable engineering trade. The reason is CPython's memory management, specifically reference counting. Every Python object keeps a count of how many references point to it; when the count hits zero, the object is freed. Those reference counts are updated constantly - every assignment, every function call. If multiple threads updated reference counts simultaneously without synchronization, you'd get race conditions corrupting the counts, leading to objects freed too early (crashes) or never freed (memory leaks).
The GIL solves this with one big lock instead of a tiny lock around every single object's reference count. The payoff: single-threaded Python is fast and simple, and C extensions are easy to write correctly because they don't have to worry about fine-grained locking. The cost: multithreaded CPU-bound Python can't use multiple cores. For most of Python's history, the simplicity and single-threaded speed were judged worth more than multi-core threading - and given how much Python is used as glue and for I/O-bound work, that judgment held up reasonably well.
Note: The GIL is a CPython implementation detail, not part of the Python language. Other implementations made different choices - Jython (on the JVM) and IronPython (on .NET) have no GIL. But CPython is what virtually everyone runs, so in practice "Python has a GIL" is true for almost all real code.
The consequences (this is the interview meat)
Everything practical about Python concurrency flows from one distinction:
CPU-bound work gets no benefit from threads - often a slight penalty. If your threads are doing pure computation (math, processing data in Python loops), they're all fighting for the one GIL token. Adding threads doesn't add parallelism; it adds context-switching overhead on top of work that's still effectively serialized. Two CPU-bound threads can actually run slower than one, because of the contention for the GIL.
I/O-bound work benefits greatly from threads. Here's the loophole: when a thread makes a blocking I/O call - reading a socket, querying a database, reading a file - it releases the GIL while it waits, because it's not executing Python bytecode during the wait, it's parked in the OS. So another thread can grab the GIL and run. With many I/O-bound threads, while each waits on its I/O, others make progress. The GIL is released exactly when threads would otherwise be idle anyway, so threading gives you real concurrency for waiting-heavy work.
Example: Concretely: 10 threads each downloading a file. Each spends 99% of its time waiting on the network - and during that wait, it has released the GIL, so the other threads run. The 10 downloads overlap and finish in roughly the time of the slowest one, not 10× sequential. Threading wins big here. Now instead: 10 threads each computing a large prime factorization in pure Python. They never do I/O, so they never voluntarily release the GIL except on the ~5ms timer - they just take turns on one core's worth of execution, plus switching overhead. The result is no faster than one thread, possibly slower. Same 10 threads, opposite outcomes, entirely because of the GIL and whether the work waits or computes.
Working around the GIL
When you genuinely need to use multiple cores for CPU-bound Python work, you have options:
multiprocessing- use separate processes instead of threads. Each process has its own Python interpreter and its own GIL, so they run truly in parallel across cores. This is the standard answer for CPU-bound parallelism, at the cost of inter-process communication overhead. (Full lesson next.)- C extensions that release the GIL - libraries like NumPy do their heavy numeric work in C and release the GIL during it, so NumPy-based computation can parallelize across threads. This is why "use NumPy" is often the real fix for slow numeric Python - the heavy lifting escapes the GIL.
asyncio- for I/O-bound concurrency, a single-threaded event loop sidesteps both threads and the GIL contention entirely (lesson after next).- Alternative interpreters - Jython, IronPython, or PyPy in some modes, though these have their own tradeoffs and ecosystem limits.
The free-threaded future
The biggest recent development: free-threaded CPython (PEP 703), introduced experimentally in Python 3.13, makes the GIL optional. A free-threaded build removes the GIL and replaces reference-count protection with finer-grained mechanisms, allowing true multi-core threading for CPU-bound Python at last. It's experimental and carries a single-threaded performance cost for now, and the ecosystem (especially C extensions) is still adapting, but it signals that the GIL's decades-long reign may be ending.
Tip: In an interview, mention free-threaded Python as awareness of where the language is going - "the GIL is being made optional in recent CPython via PEP 703" - but be clear it's experimental and not yet the default. Don't claim the GIL is gone; claim you know it's actively being addressed. That nuance signals you're current without overstating.
Seeing the GIL in code
The fastest way to believe the GIL is to watch it. Here's the CPU-bound case where threads don't help:
import threading, time
def count_down(n):
while n > 0:
n -= 1 # pure CPU work, no I/O - never releases GIL voluntarily
# Sequential
start = time.time()
count_down(50_000_000); count_down(50_000_000)
print(f"sequential: {time.time() - start:.1f}s")
# "Parallel" with 2 threads - expect NO speedup, maybe slower
start = time.time()
t1 = threading.Thread(target=count_down, args=(50_000_000,))
t2 = threading.Thread(target=count_down, args=(50_000_000,))
t1.start(); t2.start(); t1.join(); t2.join()
print(f"threaded: {time.time() - start:.1f}s") # ~same or slightly worse
Run this and the threaded version is not faster - often a touch slower - even on a multi-core machine. Both threads are pure computation, so they never release the GIL except on the periodic switch, and they end up serializing on one core's worth of execution plus switching overhead. Now swap the while loop for a time.sleep() (simulating I/O) and the threaded version does speed up, because the GIL is released during the sleep. That contrast, run on your own machine, makes the GIL real in a way no paragraph can.
How thread switching works under the GIL
A bit more mechanism, because interviewers sometimes probe it. CPython doesn't switch threads after every bytecode - that would be too costly. Instead, the running thread holds the GIL and checks roughly every 5 milliseconds (controlled by sys.setswitchinterval()) whether another thread is waiting; if so, it releases the GIL and lets another thread acquire it. This is why CPU-bound threads make slow, chunky progress in turn rather than truly interleaving instruction-by-instruction. And separately from the timer, the GIL is released immediately whenever a thread enters a blocking I/O call or a C extension that explicitly drops it. So there are two release triggers: the periodic timer (for fairness among CPU-bound threads) and voluntary release on I/O (the loophole that makes threading useful).
Note: You can tune
sys.setswitchinterval(), but you almost never should - it rarely fixes real problems and can hurt. Mention it as awareness of the mechanism, not as a tuning knob you'd reach for. The right fix for GIL contention is changing your approach (multiprocessing, async, C extensions), not micro-tuning the switch interval.
Common GIL misconceptions
Three myths worth clearing, because each shows up in interviews as a trap:
- "The GIL makes Python thread-safe, so I don't need locks." False. The GIL prevents parallel bytecode execution but not interleaving - it can release between the steps of
x += 1, so compound operations still race. You still need locks. - "The GIL means Python can't do anything concurrently." False. Python does I/O concurrency very well via threads (GIL released on I/O) and asyncio. The GIL only blocks CPU-bound parallelism specifically.
- "Multiprocessing has the same GIL problem." False. Each process has its own interpreter and own GIL, so processes achieve true parallelism. The GIL is per-interpreter, and multiprocessing gives you multiple interpreters.
Why the GIL is so hard to remove
It's natural to ask: if the GIL limits Python on multi-core machines, why has it survived for decades? The answer is a genuinely hard set of tradeoffs, and understanding them deepens your grasp of the whole topic - and impresses interviewers who expect "the GIL is just bad" and get something more thoughtful.
The root issue is CPython's reference-counting garbage collection. Every object tracks how many references point to it, and those counts change constantly - on nearly every assignment, argument pass, and scope exit. The GIL lets all of that reference-count bookkeeping happen without per-object locking, because only one thread touches Python objects at a time. Remove the GIL naively and every single reference-count update becomes a potential race that corrupts memory. To make threading safe without the GIL, you have to protect those counts some other way - and the obvious approaches, like locking each object's count individually or using atomic operations everywhere, add overhead to the most common operations in the interpreter. That overhead lands hardest on single-threaded code, which is the overwhelming majority of how Python actually runs.
So the historical tradeoff was: keep single-threaded Python fast and the interpreter simple, at the cost of multi-core threading. Given how much Python is used for scripting, glue code, and I/O-bound work - where the GIL barely matters - that bargain held up far longer than critics expected.
There's also an ecosystem dimension. A vast number of Python C extensions were written assuming the GIL exists; they rely on it to avoid doing their own locking. Removing the GIL means those extensions can break or need rework. This is why free-threaded CPython (PEP 703) is rolling out cautiously and experimentally - it's not just an interpreter change, it's a migration that the entire C-extension ecosystem has to come along for. The free-threaded build accepts some single-threaded performance cost and requires extensions to adapt, which is exactly the bargain the GIL was originally created to avoid. Whether the multi-core benefit outweighs those costs for mainstream use is precisely what the experimental period is testing.
Note: The thoughtful framing for an interview: the GIL isn't a mistake someone forgot to fix - it's a deliberate tradeoff that optimized for single-threaded simplicity and speed and for an easy C-extension model, which suited Python's dominant use cases for decades. Removing it is hard precisely because those benefits are real, and free-threaded Python is the long, careful attempt to keep them while shedding the multi-core limitation.
Tip: A useful reframe for reasoning about any Python performance problem: the GIL only matters when Python bytecode execution itself is your bottleneck. If your program spends its time waiting on I/O, or doing heavy work inside C libraries that release the GIL, the GIL is largely irrelevant and threading works beautifully. It's specifically pure-Python CPU-bound code running on multiple threads where the GIL bites. Diagnosing whether you're in that specific situation - rather than assuming the GIL is always your enemy - is what tells you whether the answer is "use threads," "use NumPy," or "use processes." The GIL is a narrow constraint that gets blamed broadly.
The interview angle
This is the Python concurrency question. Be able to deliver a crisp, complete answer.
Interview: "What is the GIL and why does it matter?" - The Global Interpreter Lock is a CPython mutex that lets only one thread execute Python bytecode at a time, so Python threads can't run Python code in parallel across cores. It exists to make CPython's reference-counting memory management thread-safe simply and to keep single-threaded execution fast. The consequence: threading gives no speedup for CPU-bound work (the threads serialize on the GIL) but works well for I/O-bound work (the GIL is released during blocking I/O, so threads overlap their waiting). For CPU-bound parallelism you use
multiprocessing(separate processes, separate GILs) or libraries that release the GIL like NumPy.Follow-ups to be ready for: "Why do you still need locks if there's a GIL?" - Because the GIL doesn't make compound operations atomic; it can release between the bytecodes of
count += 1, so that's still a race. "How do you get true parallelism in Python?" - multiprocessing, GIL-releasing C extensions, or free-threaded CPython. Nailing this whole arc is one of the highest-leverage things you can prepare for a Python interview.
Quick reference
GIL = one mutex; only one thread runs Python bytecode at a time (CPython)
Why: makes reference-counting GC thread-safe cheaply; fast single-threaded
Consequences:
CPU-bound + threads -> NO speedup (serialize on GIL), maybe slower
I/O-bound + threads -> GREAT (GIL released during blocking I/O)
Get real parallelism:
multiprocessing -> separate processes, separate GILs (CPU-bound)
NumPy / C extensions -> release GIL during heavy C work
asyncio -> single-thread event loop for I/O concurrency
free-threaded CPython -> PEP 703, experimental, GIL optional (3.13+)
Locks still needed: GIL prevents parallel bytecode, NOT interleaving
Next: the practical decision the GIL forces on every Python developer - threading versus multiprocessing - and exactly how multiprocessing gives you the true parallelism that threads can't.