What is GIL in Python and How It Impacts Performance
1. Overview
Many developers transitioning from Java or Go to Python encounter the same issue. Running 4 threads on an 8-core machine, but the program doesn’t run faster, sometimes even slower. The culprit is usually the GIL (Global Interpreter Lock).
This article explains what GIL is and why it exists. You’ll measure the impact of GIL on CPU-bound and I/O-bound code. By the end, you’ll know when to use threading, when to use multiprocessing, and what the free-threaded build of Python 3.13+ changes.
2. What is GIL?
GIL is a mutex in CPython, the most common Python implementation. At any moment, in a single process, only one thread can execute Python bytecode.
You can still create many threads. The threads are real operating system threads. But to run bytecode, a thread must hold the GIL. Other threads must wait.
2.1. Why does CPython need GIL?
The main reason is the reference counting memory management mechanism. Each object has a counter for the number of references to it:
import sys
order = {"id": 1, "total": 250_000}
print(sys.getrefcount(order))
alias = order
print(sys.getrefcount(order))
2
3
sys.getrefcount returns the reference count. The result is always at least 1 more because the function call itself creates a temporary reference. When you assign alias = order, the counter increases by 1.
If two threads increment or decrement this counter simultaneously without a lock, the counter can become incorrect. The consequence is a memory leak, or worse, an object being freed while still in use. Placing a global lock is the simplest way to protect all objects. It also makes writing C extensions easier and makes single-threaded code run faster.
2.2. When does a thread release the GIL?
A thread holding the GIL releases it in two cases:
- When waiting for I/O: reading files, making network calls,
time.sleep… These functions release the GIL before blocking. - When its turn expires: the interpreter periodically tells the running thread to release the GIL so other threads get a chance to run.
You can see the duration of a turn:
import sys
print(sys.getswitchinterval())
0.005
The default is 5 ms. You can change it with sys.setswitchinterval(), but you almost never need to.
3. Impact on CPU-bound code
Let’s measure it. The following function counts prime numbers, which is pure computation:
import time
from concurrent.futures import ThreadPoolExecutor
def count_primes(limit: int) -> int:
count = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
count += 1
return count
def run_sequential(jobs: list[int]) -> float:
start = time.perf_counter()
for job in jobs:
count_primes(job)
return time.perf_counter() - start
def run_threads(jobs: list[int]) -> float:
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(count_primes, jobs))
return time.perf_counter() - start
if __name__ == "__main__":
jobs = [200_000] * 4
print(f"Sequential: {run_sequential(jobs):.2f}s")
print(f"4 threads : {run_threads(jobs):.2f}s")
run_sequential runs 4 jobs one after another. run_threads gives 4 jobs to 4 threads. Results on an 8-core machine (for reference only):
Sequential: 2.31s
4 threads : 2.40s
Four threads are no faster. They’re even slightly slower. Because of the GIL, threads take turns running on one core. The extra slowness is the cost of contending for the GIL and context switching.
Note: This is the most common mistake. Adding threads to pure Python computation code on CPython with GIL does not make it faster.
4. Impact on I/O-bound code
With code that mostly waits for I/O, the situation is different. We simulate calling a remote PaymentService that takes 0.5 seconds:
import time
from concurrent.futures import ThreadPoolExecutor
def charge_payment(order_id: int) -> str:
time.sleep(0.5) # simulate HTTP call to payment gateway
return f"order-{order_id}: OK"
if __name__ == "__main__":
order_ids = list(range(10))
start = time.perf_counter()
for oid in order_ids:
charge_payment(oid)
print(f"Sequential : {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
list(pool.map(charge_payment, order_ids))
print(f"10 threads: {time.perf_counter() - start:.2f}s")
Sequential : 5.01s
10 threads: 0.50s
10 times faster. time.sleep, like sockets and file I/O, releases the GIL while waiting. All 10 threads wait in parallel. They only need the GIL for a moment to process results.
Conclusion: threading in Python is still very useful for I/O-bound work, like calling APIs, querying databases, or downloading files. With very large amounts of I/O, asyncio is also a good choice.
5. Bypassing GIL with multiprocessing
To leverage multiple cores for computation, the standard approach is to use multiple processes. Each process has its own interpreter and its own GIL.
import time
from concurrent.futures import ProcessPoolExecutor
def count_primes(limit: int) -> int:
count = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
count += 1
return count
if __name__ == "__main__":
jobs = [200_000] * 4
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(count_primes, jobs))
print(results)
print(f"4 processes: {time.perf_counter() - start:.2f}s")
[17984, 17984, 17984, 17984]
4 processes: 0.68s
Compared to 2.31 seconds when running sequentially, we’re about 3.4 times faster. We don’t quite reach 4 times because of the overhead of spawning processes.
The if __name__ == "__main__": block is required. On Windows and macOS, child processes are created by re-importing the module. Without this block, the child process will create a new pool and the program will crash.
Note: Multiprocessing has its own costs. Parameters and results must be serialized with
pickleto pass between processes. If you send a large object, like a DataFrame with hundreds of MB, for a small task, the copy overhead might be larger than the benefit you gain. Lambdas and nested functions also can’t be pickled.
6. C extensions can release the GIL
Many libraries written in C deliberately release the GIL when running heavy operations, like NumPy, hashlib, or zlib. In those cases, threads still run in parallel for real:
import hashlib
import os
import time
from concurrent.futures import ThreadPoolExecutor
payloads = [os.urandom(50_000_000) for _ in range(4)]
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
if __name__ == "__main__":
start = time.perf_counter()
for p in payloads:
digest(p)
print(f"Sequential: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(digest, payloads))
print(f"4 threads : {time.perf_counter() - start:.2f}s")
Sequential: 0.98s
4 threads : 0.27s
hashlib releases the GIL when hashing large data (roughly 2 KB or more). So 4 threads run on 4 cores. In fact, the GIL only blocks Python bytecode, not machine code from native libraries.
7. The GIL doesn’t make your code thread-safe
Many people think “if GIL is there, no need for locks”. This is wrong. The GIL protects the internal state of the interpreter, not the logic of your code. A thread can be switched out anywhere between any two statements.
import threading
import time
class BankAccount:
def __init__(self) -> None:
self.balance = 0
self._lock = threading.Lock()
def deposit_unsafe(self, amount: int) -> None:
current = self.balance
time.sleep(0) # release GIL, simulate an I/O operation in between
self.balance = current + amount
def deposit_safe(self, amount: int) -> None:
with self._lock:
current = self.balance
time.sleep(0)
self.balance = current + amount
def run(method_name: str, threads: int = 10, times: int = 100) -> int:
account = BankAccount()
method = getattr(account, method_name)
def worker() -> None:
for _ in range(times):
method(1)
pool = [threading.Thread(target=worker) for _ in range(threads)]
for t in pool:
t.start()
for t in pool:
t.join()
return account.balance
if __name__ == "__main__":
print("unsafe:", run("deposit_unsafe"))
print("safe :", run("deposit_safe"))
unsafe: 112
safe : 1000
We expect 10 × 100 = 1000. In deposit_unsafe, multiple threads read the same old current value and overwrite each other, so most deposits are lost. time.sleep(0) ensures the bug manifests reliably. In real code, where the GIL is released might be a log statement or a database call. deposit_safe uses threading.Lock to make the read-modify-write operation atomic.
We can write a test with pytest to lock down this behavior. Install it:
pip install pytest
# test_bank_account.py
from bank_account import run
def test_safe_deposit_keeps_correct_balance():
assert run("deposit_safe", threads=8, times=200) == 1600
def test_unsafe_deposit_loses_updates():
assert run("deposit_unsafe", threads=8, times=200) < 1600
pytest -q test_bank_account.py
.. [100%]
2 passed in 0.41s
The second test proves that a race condition exists even with CPython’s GIL.
8. Python free-threaded: a future without GIL
Starting with Python 3.13, CPython has a free-threaded build (PEP 703) that allows disabling the GIL. You typically run this via python3.13t or python3.14t. In Python 3.14, this build is officially supported (PEP 779), but it’s still not the default.
Check the GIL status at runtime:
import sys
import sysconfig
print("Build supports free-threading:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL is enabled:", sys._is_gil_enabled())
Run with the free-threaded build:
python3.14t check_gil.py
Build supports free-threading: True
GIL is enabled: False
With this build, the count_primes example using ThreadPoolExecutor from section 3 will actually leverage multiple cores.
However, consider a few points before switching:
- Single-threaded code is slightly slower, because the interpreter must use more sophisticated synchronization mechanisms.
- C extensions must be compatible. If you import an extension that doesn’t declare free-threading support, CPython will automatically re-enable the GIL and print a warning. You can force it with
PYTHON_GIL=0, but you’re taking on the risk yourself. - Race conditions are more likely. For example, the situation in section 7 is no longer “hidden” by the fact that only one thread runs at a time.
9. Which tool to choose?
| Type of work | Use |
|---|---|
| I/O-bound (HTTP, DB, file) | threading, ThreadPoolExecutor, asyncio |
| Pure Python CPU-bound | ProcessPoolExecutor / multiprocessing |
| CPU-bound on NumPy, hashlib… | Threading still works, the library releases the GIL |
| CPU-bound, needs shared memory, compatible libraries | Consider Python free-threaded (3.13t/3.14t) |
Note: Don’t optimize based on intuition. Measure with
time.perf_counter()orcProfilefirst. Many programs said to be “slow because of GIL” are actually slow because of the algorithm or database queries.
10. Conclusion
The GIL is CPython’s global lock. It ensures only one thread runs Python bytecode at a time, mainly to protect reference counting.
The practical consequences are four points:
- Threading does not speed up pure Python CPU-bound code.
- Threading is still very effective for I/O-bound work and for native libraries that release the GIL.
- To leverage multiple cores for computation, use multiprocessing, or try the free-threaded build of Python 3.13+.
- The GIL is not a substitute for locks. Data shared between threads still needs proper synchronization.