Skip to content
Codeloom
Python

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.

·6 min read · By Codeloom
Advanced 11 min read

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:

  1. Is the workload I/O-bound? Use asyncio or threading
  2. Is the workload CPU-bound with pure Python? Use multiprocessing
  3. Is the workload CPU-bound but uses NumPy/C extensions? Threading may work (the library releases the GIL)
  4. Do you need shared state between workers? Use threading with locks, or multiprocessing with 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.