The Python GIL Explained: What It Is and When It Matters
Understand the Global Interpreter Lock in CPython, why it exists, how it affects concurrency, and strategies to work around it.
What you'll learn
- ✓What the GIL is and why CPython has it
- ✓How the GIL affects CPU-bound vs I/O-bound workloads
- ✓Strategies for parallelism despite the GIL
- ✓The free-threaded Python initiative and what it means for the future
Prerequisites
None — this post is self-contained.
The Global Interpreter Lock is one of the most misunderstood features of Python. It is a mutex in CPython that allows only one thread to execute Python bytecode at a time. This does not mean Python cannot do concurrency. It means you need to understand what the GIL does and does not prevent so you can choose the right concurrency strategy.
What the GIL Actually Is
The GIL is a lock inside CPython’s interpreter. Before a thread can execute any Python bytecode, it must acquire the GIL. Only one thread holds it at any moment. The interpreter periodically releases it (every 5 milliseconds by default in Python 3.2+) to give other threads a chance.
import sys
print(sys.getswitchinterval()) # 0.005 (5 milliseconds)
Why Does It Exist?
CPython’s memory management uses reference counting. Every object has a reference count that increments and decrements as references are created and destroyed. Without the GIL, every reference count update would need its own lock, which would:
- Add significant overhead to every single Python operation
- Risk deadlocks from the many fine-grained locks needed
- Make the C extension API far more complex
The GIL is a pragmatic tradeoff: one coarse lock instead of thousands of fine-grained ones. It simplifies CPython’s implementation and makes C extensions easier to write.
When the GIL Does Not Matter
I/O-Bound Workloads
When a thread performs I/O — network requests, file reads, database queries — it releases the GIL while waiting. Other threads can run during that time.
import threading
import time
import urllib.request
def fetch(url):
urllib.request.urlopen(url).read()
urls = ["https://example.com"] * 10
# Sequential: ~5 seconds
start = time.time()
for url in urls:
fetch(url)
print(f"Sequential: {time.time() - start:.2f}s")
# Threaded: ~0.5 seconds (10x faster)
start = time.time()
threads = [threading.Thread(target=fetch, args=(url,)) for url in urls]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Threaded: {time.time() - start:.2f}s")
Threading gives real speedup for I/O-bound work because threads release the GIL while waiting for I/O.
C Extensions
Many libraries release the GIL when calling into C code. NumPy, for example, releases the GIL during array operations:
import numpy as np
import threading
# NumPy releases the GIL for large array operations
# Multiple threads can do NumPy math simultaneously
def compute(arr):
return np.linalg.svd(arr)
arrays = [np.random.rand(1000, 1000) for _ in range(4)]
threads = [threading.Thread(target=compute, args=(a,)) for a in arrays]
for t in threads:
t.start()
for t in threads:
t.join()
When the GIL Hurts
CPU-Bound Workloads
If your threads do pure Python computation, the GIL serializes them. Adding threads does not speed things up and can even slow things down due to lock contention:
import threading
import time
def cpu_work(n):
total = 0
for i in range(n):
total += i * i
return total
N = 50_000_000
# Single thread
start = time.time()
cpu_work(N)
print(f"Single thread: {time.time() - start:.2f}s")
# Two threads (NOT faster -- GIL serializes them)
start = time.time()
t1 = threading.Thread(target=cpu_work, args=(N // 2,))
t2 = threading.Thread(target=cpu_work, args=(N // 2,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Two threads: {time.time() - start:.2f}s")
The two-thread version takes approximately the same time (or longer) as the single-thread version.
Strategies for Parallelism
multiprocessing — Separate Interpreters
Each process has its own GIL, so true parallelism is possible:
from multiprocessing import Pool
import time
def cpu_work(n):
total = 0
for i in range(n):
total += i * i
return total
N = 50_000_000
# Multiprocessing: actually parallel
start = time.time()
with Pool(4) as pool:
results = pool.map(cpu_work, [N // 4] * 4)
print(f"4 processes: {time.time() - start:.2f}s")
The tradeoff is higher memory usage (each process has its own Python interpreter) and inter-process communication overhead.
concurrent.futures — Unified API
The concurrent.futures module provides the same interface for threads and processes:
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
import time
def download(url):
"""I/O bound -- use threads."""
import urllib.request
return urllib.request.urlopen(url).read()
def compute(n):
"""CPU bound -- use processes."""
return sum(i * i for i in range(n))
# Thread pool for I/O
with ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(download, "https://example.com") for _ in range(10)]
results = [f.result() for f in futures]
# Process pool for CPU
with ProcessPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(compute, 10_000_000) for _ in range(4)]
results = [f.result() for f in futures]
asyncio — Cooperative Concurrency
For I/O-bound workloads, asyncio avoids threads entirely. A single thread switches between tasks cooperatively:
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, "https://example.com") for _ in range(100)]
results = await asyncio.gather(*tasks)
print(f"Fetched {len(results)} pages")
asyncio.run(main())
No GIL contention because there is only one thread.
The Free-Threaded Python Initiative
PEP 703, accepted for Python 3.13+, introduces an experimental build of CPython without the GIL (the “free-threaded” or “no-GIL” build). Key points:
- It is opt-in: you must build or install CPython with
--disable-gil - Reference counting is replaced by a more complex scheme using biased reference counting and deferred reference counting
- Performance of single-threaded code may be slightly slower (5-10% in early benchmarks)
- C extensions need updates to be thread-safe without the GIL
- The transition is expected to take several years
# Python 3.13+ experimental free-threaded build
python3.13t -c "import sys; print(sys._is_gil_enabled())"
# False
This is a significant development, but for production code today, the GIL is still present in standard CPython builds.
Decision Flowchart
When choosing a concurrency strategy:
- Is the workload I/O-bound? Use
asyncioorthreading - Is the workload CPU-bound with pure Python? Use
multiprocessing - Is the workload CPU-bound but uses NumPy/C extensions? Threading may work (the library releases the GIL)
- Do you need shared state between workers? Use
threadingwith locks, ormultiprocessingwith shared memory
Key Takeaways
The GIL prevents multiple threads from executing Python bytecode simultaneously, but it does not prevent concurrency. I/O-bound programs benefit fully from threading and asyncio. CPU-bound programs need multiprocessing or C extensions that release the GIL. The free-threaded CPython build is coming but is not yet ready for production. Understanding the GIL helps you pick the right tool instead of fighting the language.
Related articles
- Python Python Concurrency: asyncio vs threading vs multiprocessing
Compare Python's concurrency models side by side. Learn when to use asyncio, threading, or multiprocessing with practical benchmarks and real-world examples.
- Python Python Profiling: Find and Fix Performance Bottlenecks
Learn how to profile Python code with cProfile, line_profiler, memory_profiler, and timeit to identify slow functions, memory leaks, and optimize runtime performance.
- Python Advanced asyncio Patterns for Production Python
Go beyond basic async/await with structured concurrency, task groups, semaphores, cancellation, and error handling patterns in Python asyncio.
- Python Python Concurrency: Threading vs Multiprocessing vs Asyncio
Compare Python's concurrency models -- threading, multiprocessing, and asyncio -- with practical examples and performance guidance.