Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 062 / 100

The GIL explained: a Python Backend guide for developers

Why Python threads do not run CPU work in parallel — yet.

Expona Admin7 min read

If you've ever deployed a Python backend and found that threads don't speed up CPU-heavy workloads, you've encountered the Global Interpreter Lock. The GIL explained: a Python Backend guide for developers clarifies why this limit exists, when it matters in production, and how to work around it—especially as free-threaded Python changes the landscape.

The GIL lets one thread run Python bytecode at a time

At the heart of CPython (the reference implementation most of us use) sits a mutex called the Global Interpreter Lock. This lock ensures that only one thread executes Python bytecode at any given moment, regardless of how many CPU cores your server has. When a thread wants to run Python code, it must acquire the GIL. When it releases the GIL—either voluntarily or after a time slice—another thread gets a chance.

The GIL exists because CPython's memory management relies on reference counting. Every Python object carries a reference count, and when that count reaches zero, the object is freed immediately. Without a global lock, every reference count modification would need fine-grained locking around each object, creating bottlenecks and race conditions that would hobble single-threaded performance.

Consider a compute-heavy function that runs on your backend:

python
def cpu_intensive_work(n):
    total = 0
    for i in range(n):
        total += sum(range(1000))
    return total

# Attempting to parallelise with threads
import threading

def thread_worker(n, results, index):
    results[index] = cpu_intensive_work(n)

results = [None, None]
threads = [
    threading.Thread(target=thread_worker, args=(10_000_000, results, 0)),
    threading.Thread(target=thread_worker, args=(10_000_000, results, 1))
]

start = time.time()
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"Threaded time: {time.time() - start:.2f}s")

On a dual-core machine, you might expect this to run in roughly half the time of sequential execution. Instead, it often runs slower than the sequential version because thread switching and GIL contention add overhead without parallelism. The CPU-bound work is still serialised by the lock.

Threads still help for I/O

The GIL's impact depends entirely on what your thread is doing. When a thread makes a blocking I/O call—reading from a socket, querying a database, writing to disk—it releases the GIL while waiting. This allows other threads to acquire the lock and run Python code. For I/O-bound workloads, threading is genuinely effective.

A typical web backend spends most of its time waiting: waiting for HTTP requests, database queries, third-party API calls, or Redis operations. During these waits, the GIL is released, so multiple threads can make progress. This is why frameworks like Flask and Django work well with threaded application servers in production.

Here's a practical example:

python
import threading
import time
import requests

def fetch_url(url, results, index):
    # This I/O operation releases the GIL
    response = requests.get(url, timeout=5)
    results[index] = len(response.content)

urls = [
    "https://api.example.com/endpoint1",
    "https://api.example.com/endpoint2",
    "https://api.example.com/endpoint3",
]

results = [None] * len(urls)
start = time.time()

threads = [
    threading.Thread(target=fetch_url, args=(url, results, i))
    for i, url in enumerate(urls)
]

for t in threads:
    t.start()
for t in threads:
    t.join()

print(f"Threaded I/O time: {time.time() - start:.2f}s")
print(f"Results: {results}")

When each HTTP request takes 1 second and you have three threads, the total time is approximately 1 second (concurrent) rather than 3 seconds (sequential). The threads cooperatively release the GIL during network waits, enabling real parallelism for I/O-bound work.

This is why production Python backends typically use:

  • Gunicorn with multiple worker processes for CPU isolation across requests
  • Threaded application servers
  • Thread pools (ThreadPoolExecutor) for background tasks that involve network or disk I/O

The key principle: threads excel when your code spends time waiting, not computing.

Multiprocessing for CPU-bound work

When you need true parallelism for CPU-bound tasks, multiprocessing is the established solution. Each process has its own Python interpreter and its own GIL. They run independently on separate CPU cores, achieving genuine parallelism. The trade-off is higher memory overhead and more complex inter-process communication.

Let's revisit our CPU-intensive example using multiprocessing:

python
import multiprocessing
import time

def cpu_intensive_work(n):
    total = 0
    for i in range(n):
        total += sum(range(1000))
    return total

def process_worker(n, queue):
    result = cpu_intensive_work(n)
    queue.put(result)

if __name__ == "__main__":
    queue = multiprocessing.Queue()
    n = 10_000_000
    
    start = time.time()
    p1 = multiprocessing.Process(target=process_worker, args=(n, queue))
    p2 = multiprocessing.Process(target=process_worker, args=(n, queue))
    
    p1.start()
    p2.start()
    
    p1.join()
    p2.join()
    
    result1 = queue.get()
    result2 = queue.get()
    
    print(f"Multiprocessing time: {time.time() - start:.2f}s")
    print(f"Results: {result1}, {result2}")

On a dual-core machine, this runs in approximately half the time of the sequential version. Each process has its own GIL, so they execute in true parallel.

In production backends, multiprocessing is useful for:

  • Background task queues: Worker processes processing items from Celery, RQ, or similar. Each worker is a separate process.
  • Data processing pipelines: Batch jobs that transform large datasets benefit from splitting work across multiple processes.
  • Machine learning inference: Model predictions can be parallelised across processes when serving predictions in real-time.

The downside is memory usage—each process duplicates the Python runtime and loaded modules. For a large application, spawning dozens of processes becomes expensive. Additionally, sharing state between processes requires serialisation (typically pickle), which adds latency and complexity.

A common pattern in production is to use a process pool with a fixed number of workers:

python
from multiprocessing import Pool

def heavy_computation(item):
    return sum(range(item * 1000))

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        results = pool.map(heavy_computation, range(1000))
        print(f"Processed {len(results)} items")

This balances parallelism with resource efficiency by reusing a fixed number of worker processes.

Free-threaded Python 3.13+ is changing this

Starting with Python 3.13, the reference implementation introduced an experimental "free-threaded" build (sometimes called "no-GIL Python"). This build removes the Global Interpreter Lock, allowing true parallelism within threads. While this is still early, it represents a fundamental shift in Python's concurrency model.

In free-threaded Python 3.13+, the following code actually achieves parallel execution on multiple cores:

python
import threading
import time

def cpu_intensive_work(n):
    total = 0
    for i in range(n):
        total += sum(range(1000))
    return total

def thread_worker(n, results, index):
    results[index] = cpu_intensive_work(n)

results = [None, None]
threads = [
    threading.Thread(target=thread_worker, args=(10_000_000, results, 0)),
    threading.Thread(target=thread_worker, args=(10_000_000, results, 1))
]

start = time.time()
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"Free-threaded time: {time.time() - start:.2f}s")  # Now actually ~2x faster

Free-threaded Python replaces the global lock with fine-grained locking around individual objects. This removes the GIL bottleneck but introduces new performance considerations: lock contention, cache coherency issues, and slightly higher per-thread overhead.

Important caveats for production adoption:

  • Experimental status: As of Python 3.13, free-threading is still evolving. Not all C extensions support it yet.
  • Performance trade-offs: Single-threaded code may be slightly slower due to fine-grained locking overhead.
  • Gradual adoption: Major libraries (NumPy, Pandas, Django) need updates to fully leverage free-threading.
  • Backward compatibility: Existing GIL-based code works unchanged, but you must opt into free-threaded builds explicitly.

For most backends in the next 2–3 years, the conventional advice remains unchanged: use threads for I/O, multiprocessing for CPU-bound work. But as free-threading matures and library support improves, we'll likely see a simplification of Python's concurrency landscape.

Common mistakes

Mistake 1: Assuming threads speed up CPU work. Newcomers often spawn multiple threads expecting parallelism, only to see performance degrade due to GIL contention. Always profile first. If your workload is CPU-bound, use multiprocessing.

Mistake 2: Overusing multiprocessing for I/O work. Spinning up multiple processes for a few HTTP requests wastes memory and adds complexity. Keep processes for genuinely CPU-intensive tasks; use threads for I/O.

Mistake 3: Ignoring serialisation overhead. Passing large objects between processes requires pickling, which is slow. If your inter-process communication is frequent and heavy, reconsider your architecture—perhaps use a queue-based service instead.

Mistake 4: Not measuring contention. Even I/O-bound code can suffer if threads are poorly tuned. Too many threads cause context switching overhead; too few underutilise resources. Use profiling tools (cProfile, py-spy) to identify actual bottlenecks.

Key takeaways

  • The GIL ensures one thread runs Python bytecode at a time; it exists to simplify memory management via reference counting.
  • Threads are effective for I/O-bound workloads because the GIL is released during blocking I/O calls like network requests and database queries.
  • For CPU-bound work, use multiprocessing to achieve true parallelism across cores, accepting the trade-off of higher memory usage.
  • Thread pools (ThreadPoolExecutor) and process pools (multiprocessing.Pool) are practical tools for managing concurrency in production backends.
  • Free-threaded Python 3.13+ removes the GIL but is still experimental; adoption depends on library support and performance validation in your use case.
  • Always profile your specific workload—assumptions about threading rarely match reality in production systems.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles