Topic 12.2
The GIL and Free-Threaded Python
In one line
The Global Interpreter Lock (GIL) lets only one thread run Python bytecode at a time in the standard CPython build, so threads do not speed up pure-Python calculation but still help with waiting. A free-threaded build without the GIL exists (experimental in 3.13, officially supported in 3.14).
Think of it like this
A kitchen with many cooks but one cookbook, and the rule that only the cook holding the cookbook may read from it. Cooks can chop and stir freely while they hold nothing, but to read the next step they must take the book. The GIL is that book: a lock that only one thread at a time can hold while it runs Python code.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- GIL
- Global Interpreter Lock: the lock that lets only one thread run Python bytecode at a time in standard CPython.
- CPython
- The standard Python, written in C. Other implementations exist, such as PyPy.
- Reference count
- A number stored in each object that counts how many names point at it. At zero the object is freed.
- Switch interval
- How long a thread may hold the GIL before it is asked to let another thread run (5 ms by default).
- Free-threaded build
- A CPython build without the GIL, so threads can run Python code on several cores at once.
- Sub-interpreter
- A separate, isolated Python interpreter running inside the same process.
- Bytecode
- The low-level instructions CPython runs. One line of Python is often several bytecode instructions.
Step by step
01One line is many steps
Thread-safe means correct even when several threads use it at the same time. To see why the GIL does not make your code thread-safe, look at what counter += 1 becomes. The dis module (Topic 13.3) shows the bytecode: first the value is read, then one is added, then the result is stored. They are separate instructions.
The function uses global counter so it can change the module-level name (Topic 5.5).
import dis
counter = 0
def add_one():
global counter
counter += 1
dis.dis(add_one)02Where a switch can happen
The GIL is handed over only at certain points between bytecode instructions, but you must assume it can happen at any of them. If thread A has done the read (LOAD_GLOBAL) and then the interpreter switches to thread B, B reads the same old value. Both add one and both store, so two increments produced one. The GIL kept the interpreter's memory safe, but your program's logic is still wrong.
03CPU-bound threads gain nothing
Imagine two threads, each running a loop of pure-Python arithmetic. Only the thread holding the GIL makes progress, so the pair takes about as long as running the two loops one after the other (and a little longer because of switching). The cores that are idle cannot help, because the rule is about Python bytecode, not about the hardware.
If you want to see this yourself, time the same function sequentially and in two threads on a normal Python. Do it on your computer: the browser cannot start threads, and timings are never stable enough to print in a course.
04Waiting releases the lock
A thread that calls time.sleep, reads a socket or waits on a file gives the GIL away before it blocks. While it waits, another thread runs Python. So ten threads that each wait one second for a server finish in about one second, not ten. This is why a thread pool is a perfectly good answer for I/O-bound code even with the GIL.
05Asking Python about its GIL
sys.getswitchinterval() reports the switch interval. From Python 3.13 on, sys._is_gil_enabled() tells you whether this interpreter is running with the GIL (it returns False only in a free-threaded build with the GIL turned off), and sysconfig.get_config_var("Py_GIL_DISABLED") tells you whether the interpreter was built as free-threaded. The leading underscore means "not a stable public API", so use it for diagnostics only.
06Free-threaded and sub-interpreters
In a free-threaded build, object reference counts and built-in containers use finer-grained protection, and threads run Python on several cores. Your own shared data still needs locks. A program that was correct only because the GIL happened to hide a race can break there, so test threaded code on the build you will ship.
Sub-interpreters give each interpreter its own state, so there is little shared mutable data to protect. Passing data between them copies or shares it explicitly, much like processes but inside one process.
Try it yourself
- 1
Predict the bytecode
Before running the second example, write down how many bytecode instructions you expect for
counter += 1. Run it and count. Then changecounter += 1tocounter = 0and compare: a plain assignment is one store, and storing a single value is the kind of step that cannot be split. - 2
Read the switch interval
In the first example, print
sys.getswitchinterval() * 1000to see the interval in milliseconds. Then say in your own words why a smaller interval makes threads feel more responsive but costs more switching.
Code & diagrams
The browser runs the standard 3.13 build, so the GIL is on. On a free-threaded build the second and third lines would differ.
Expected output
Python: 3 13
GIL enabled right now: True
Free-threaded build: False
Thread switch interval (seconds): 0.005Read the instructions between RESUME and RETURN. Exact bytecode differs between Python versions, but the read, add, store shape does not.
Expected output
6 RESUME 0
8 LOAD_GLOBAL 0 (counter)
LOAD_CONST 1 (1)
BINARY_OP 13 (+=)
STORE_GLOBAL 0 (counter)
RETURN_CONST 0 (None)Not runnable here. Free-threaded builds are installed separately (for example from python.org installers or with uv). The expected answer on such a build is False.
python3.14t -c "import sys; print(sys._is_gil_enabled())"Output
FalseNot runnable here. Each interpreter is isolated, so it can run in parallel with the main one without sharing its objects. Rarely needed today; treat it as something to watch.
from concurrent import interpreters
interp = interpreters.create()
interp.exec("print('hello from another interpreter')")Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Call a 3.13 function on an older Python
Run the first example on a Python older than 3.13 (for example 3.12) on your own computer.
import sys
print("GIL enabled:", sys._is_gil_enabled())Myth vs fact
Myth
The GIL makes Python code thread-safe.
Fact
It protects the interpreter's internals only. Operations such as counter += 1 are several steps and can still be interleaved, so shared data needs locks.
Myth
Threads are useless in Python because of the GIL.
Fact
They are fine for I/O-bound work because waiting releases the GIL, and C libraries such as NumPy release it too. They are only useless for CPU-bound pure-Python code.
Myth
The GIL is a bug that nobody has fixed.
Fact
It is a design choice with real benefits (simple, fast single-threaded code). Removing it took years of careful work and is now available as the free-threaded build.
Myth
Python 3.14 removed the GIL for everyone.
Fact
The standard build still has the GIL. The free-threaded build is a separate, officially supported option.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The GIL is released around blocking system calls and inside many C extensions (NumPy, hashing, compression, many database drivers). That is why a thread pool of NumPy-heavy tasks can use several cores while a pool of pure-Python tasks cannot.
- ▸
Free-threaded CPython makes reference counting thread-safe with techniques such as biased and deferred counting, and thread-safe containers use per-object locks. Single-threaded code runs somewhat slower than on the standard build, and extension modules must declare they support running without the GIL; otherwise the interpreter turns the GIL back on when they are imported (check
sys._is_gil_enabled()after your imports). - ▸
Since Python 3.12 each sub-interpreter can have its own GIL (PEP 684), which is the groundwork for the 3.14
concurrent.interpretersmodule (PEP 734). Both free-threading and sub-interpreters are active areas, so check the current release notes before relying on details. - ▸
A race that does not show up is still a race. In recent CPython versions the request to switch threads is acted on only at certain instructions (calls, backward jumps in loops). A plain
counter += 1inside a loop contains none of them between its read and its write, so on a normal 3.12 build four threads adding 1,000,000 each usually give the exact total. Put a function call between the read and the write (counter += one()) and updates are lost within seconds. The lesson is not that the first form is safe: that is an implementation detail which changes between versions and which the free-threaded build removes. - ▸
You can switch the GIL on or off for a free-threaded build with the environment variable
PYTHON_GIL=1or0(or-X gil=1or-X gil=0). That is how projects test whether their dependencies still work without it. On the standard build the setting has no effect.
Remember this
- 1
Why does it exist? CPython (the standard Python, Phase 0 Topic 0.8) keeps a reference count on every object to know when to free it (Topic 13.2). If two threads changed one count at the same moment, the count could end up wrong and memory would be freed too early or never. The simplest safe fix is one big lock around the interpreter. It also made C extensions (add-on modules written in C, such as NumPy) simple to write.
- 2
What it means in practice: a thread running pure-Python code holds the GIL, so a second thread running Python code must wait. Two threads doing heavy Python maths take about as long as one thread doing both jobs, and sometimes longer because of the switching. Threads do not give you more speed for CPU-bound Python.
- 3
I/O releases the GIL. While a thread waits for the network, a file, or
time.sleep, it lets go of the GIL, so another thread can run Python. That is why threads do help I/O-bound programs (Topic 12.1). Many C libraries also release the GIL during long calculations, so NumPy and similar code can use several cores even from threads. - 4
The interpreter forces a switch regularly: a thread that has held the GIL for the switch interval (5 milliseconds by default,
sys.getswitchinterval()) is asked to give it up. The thread then hands the lock over at the next point where the interpreter checks for such a request, and those points are frequent (function calls, jumps back to the start of a loop and others), so as far as your reasoning goes a switch can happen between steps of a statement. The GIL protects the interpreter's own data, not your data:counter += 1is several bytecode steps, and a switch between them still loses an update. You still need locks in threaded code (Topic 12.3). - 5
Free-threaded Python removes the GIL. It was an experimental build option in Python 3.13 and is officially supported in Python 3.14. It is a separate build (often called
python3.13torpython3.14t), where threads can run Python code on several cores at once. Some C extensions must be updated to work in it, and single-thread speed is somewhat lower. The standard build, with the GIL, stays the default. - 6
Sub-interpreters are another route to parallelism: several isolated interpreters inside one process, each with its own state and (since 3.12) its own GIL. Python 3.14 adds the
concurrent.interpretersmodule to use them from Python code. You will rarely need it yet; processes and the free-threaded build are the main options.
Explain it without notes
What is the GIL, and what does it protect?
Why do threads speed up an I/O-bound program but not a CPU-bound pure-Python one?
If the GIL exists, why do you still need locks in threaded Python code?
What are the free-threaded build and sub-interpreters, and what is their status?
Your threaded counter test passes on your machine, even with 4 threads and a million increments each and no lock. Is the code safe? What would you do?
Practice
Print whether your interpreter runs with the GIL, in the form GIL: True, using a safe fallback for versions older than 3.13.
Use dis to show the bytecode of a function bump(d, k) that does d[k] += 1. Which instructions read, add and store?
Explain in one sentence each why list.append from many threads does not corrupt the list, while counter += 1 can lose updates.
Trade-offs
- ↔
GIL (standard build): fast single-threaded code and a huge extension ecosystem, but no parallel Python in threads.
- ↔
Free-threaded build: real thread parallelism, but slightly slower single-thread speed, a smaller set of ready extensions, and races that the GIL used to hide become visible.
- ↔
Processes instead of threads: work on every version and every build, at the price of copying data between processes.
Done when you can
Done when you can explain what the GIL is with the one-cookbook analogy.
Done when you can say why threads help I/O-bound code but not CPU-bound pure-Python code.
Done when you can show with
disthatcounter += 1is more than one step.Done when you can say which Python versions introduced the experimental and the officially supported free-threaded build.
Done when you can check at run time whether the GIL is on, with a safe fallback.