Python has one peculiarity that makes concurrent programming harder. It’s called the Python GIL, short for Global Interpreter Lock. The GIL makes sure there is, at any time, only one thread running Python code. Because of that, in the regular Python build, threads can’t use multiple processors to run Python code in parallel. But don’t worry, there are ways around this. You can use the multiprocessing library, and since Python 3.13 there is an optional free-threaded build of Python that has no GIL at all.
Thread-safety
As mentioned already, Python threads share the same memory. With multiple threads running simultaneously, we don’t know the order in which the threads access shared data. Therefore, the result of accessing shared data is dependent on the scheduling algorithm. This algorithm decides which thread runs when. Threads are “racing” to access/change the data.
- Thread safety
- Thread-safe code only manipulates shared data in such a way, that it does not interfere with other threads.
The GIL was invented because CPython’s memory management is not thread-safe. With only one thread running at a time, the interpreter’s own internals, like reference counts and the memory allocator, can’t get corrupted.
That protection stops at the interpreter, though. The GIL does not make your own code thread-safe. Python can switch from one thread to another in the middle of a line like a = a + 2, after reading a but before writing the result back. So even with a GIL, you still need locks when threads change shared data.
A demonstration of a race condition
As an example, let’s create a shared Python variable called a, with a value of 2:
a = 2
Now suppose we have two threads, thread_one and thread_two. They perform the following operations:
- thread_one:
a = a + 2 - thread_two:
a = a * 3
If thread_one is able to access a first and thread_two second, the result will be:
- a = 2 + 2,
ais now 4. - a = 4 * 3,
ais now 12.
However, if it so happens that thread_two runs first, and then thread_one, we get a different output:
- a = 2 * 3,
ais now 6 - a = 6 + 2,
ais now 8
So the order of execution obviously matters for the output. There’s an even worse possible outcome, though! What if both threads read variable a at the same time, do their thing, and then assign the new value? They will both see that a = 2. Depending on who writes its result first, a will eventually be 4 or 6. Not what we expected! This is what we call a race condition.
- Race condition
- The condition of a system where the system’s behavior is dependent on the sequence or timing of other, uncontrollable events.
Race conditions are difficult to spot, especially for software engineers that are unfamiliar with these issues. Also, they tend to occur randomly, causing erratic and unpredictable behavior. These bugs are notoriously difficult to find and debug.
The GIL won’t save you
You don’t have to take my word for it. Here, four threads each add 1 to a shared balance a million times, so you would expect 4,000,000 at the end:
import threading
balance = 0
def add_one(value):
return value + 1
def deposit_many():
global balance
for _ in range(1_000_000):
balance = add_one(balance)
threads = [threading.Thread(target=deposit_many) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(balance)
On the regular Python 3.15 build, with the GIL, three runs printed this on my machine:
1956234 2304105 1987702
About half of the deposits got lost. A thread reads balance, Python switches to another thread during the call to add_one(), and when the first thread writes its result back, it overwrites the work of the other one. The numbers will be different every time you run it.
The fix is a threading.Lock. Only one thread at a time can hold the lock, so the read and the write happen together:
import threading
balance = 0
lock = threading.Lock()
def add_one(value):
return value + 1
def deposit_many():
global balance
for _ in range(1_000_000):
with lock:
balance = add_one(balance)
threads = [threading.Thread(target=deposit_many) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(balance) # 4000000
This prints 4000000 every time, on both the regular and the free-threaded build. So what does the GIL buy you, then? It keeps Python itself from crashing or corrupting its own memory. It doesn’t keep your data correct. That part is still your job.
When does the GIL get in the way?
Not as often as you might think. A thread that waits for I/O, like a network response, a database query or a file read, releases the GIL while it waits. Other threads can run in the meantime. That’s why threads work well for I/O-bound programs, even on the regular build. The same goes for asyncio, which handles many waits in a single thread.
The GIL hurts when your threads do CPU-bound work in pure Python, like number crunching in loops. With the GIL, those threads take turns on one core, so adding threads doesn’t make the work finish sooner. Many C extensions, like NumPy, release the GIL during heavy computations, so they’re less affected.
Can we get rid of the Python GIL?
If the GIL holds us back in terms of concurrency, shouldn’t we get rid of it or be able to turn it off? It’s not that easy. Other features, libraries, and packages have come to rely on the GIL, so something must replace it, or else the entire ecosystem will break. This turned out to be a difficult problem to solve, but it’s being solved right now.
In 2021, Sam Gross published a proof-of-concept version of CPython without the GIL. That work became PEP 703, which was accepted in 2023. The result is a second build of Python, called the free-threaded build:
- Python 3.13 shipped it as an experimental option.
- Since Python 3.14, it is officially supported (PEP 779).
- In Python 3.15 it is still optional. The default Python you download still has a GIL.
How to get a free-threaded Python
The free-threaded interpreter has a t at the end of its name: python3.15t. The easiest way to get it on any OS is with uv:
uv python install 3.15t uv run --python 3.15t python my_script.py
On Windows, the Python install manager can install it too (py install 3.15t), and the python.org installer for macOS has it as an option. To check what you’re running, look at python -VV, which says “free-threading build”, or ask Python whether the GIL is on right now:
$ python3.15t -VV Python 3.15.2 free-threading build (main, Oct 26 2026, 13:59:30) [Clang 23.1.3 ] $ python3.15t -c "import sys; print(sys._is_gil_enabled())" False $ python3 -c "import sys; print(sys._is_gil_enabled())" True
The trade-offs
With the free-threaded build, threads doing CPU-bound work really do run in parallel on multiple cores. My Python threading article shows the difference with a timing example. There are a few things to keep in mind, though:
- Single-threaded code runs a little slower. The Python documentation puts the average overhead between about 1% (macOS on Apple silicon) and 8% (x86-64 Linux). It also uses more memory.
- Packages with C extensions must support it. If you import an extension that isn’t marked as free-threading safe, Python turns the GIL back on and prints a warning. Most popular packages support it by now; the free-threading guide tracks which ones do.
- You need locks even more. Race conditions like the one above show up more often without a GIL. When I ran the unlocked example with
python3.15t, only about 1.1 million of the 4 million deposits survived.
If you want to read more about the history of the GIL and why it was so hard to remove, the Python wiki and PEP 703 are good places to start.