The Global Interpreter Lock attracts explanations that are memorable, confident, and incomplete:

  • "Python cannot do concurrency."
  • "Only one thread ever runs."
  • "Threads are useless for CPU work."
  • "List append is thread-safe, so no lock is needed."
  • "Python 3.13 removed the GIL."

Each slogan compresses several different questions into one. Which Python implementation? Which build? Is the work Python bytecode, native code, or waiting? Does "safe" mean memory-safe, atomic, or logically correct? Are we discussing latency, throughput, CPU utilization, or program structure?

This tutorial replaces the slogans with a workload model. It covers default CPython, the free-threaded build introduced experimentally in Python 3.13 and officially supported in Python 3.14, and the choices available when one process must use more than one core.

Version note. Examples were verified on the default GIL-enabled CPython 3.14.7 build on macOS arm64. Free-threaded behavior is described from Python 3.14's canonical documentation; benchmark results from a GIL-enabled build must not be presented as measurements of a free-threaded build.

Begin with two words: concurrency and parallelism

Concurrency means multiple tasks can make progress during overlapping periods. Parallelism means work is executing at the same instant, usually on multiple cores.

One thread can run an event loop that handles thousands of concurrent sockets without executing two Python instructions at once. Four processes can execute Python code in parallel while sharing no ordinary Python objects. Four threads can overlap network waits even in a GIL-enabled process. A native numeric library can release the GIL and run parallel kernels while Python threads wait for results.

Those are different mechanisms. Treating "concurrent" as a synonym for "parallel" makes every later decision harder.

Here is real concurrency in a default CPython build:

from concurrent.futures import ThreadPoolExecutor
from time import sleep


def wait_for_service(delay):
    sleep(delay)
    return delay


with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(wait_for_service, [0.2] * 4))

print(results)

time.sleep() releases the GIL while the thread is blocked. The four waits overlap, so the program finishes in roughly one wait interval rather than four. No two threads need to execute the Python function body simultaneously for the overlap to be useful.

What the default CPython GIL protects

The default CPython runtime uses reference counting and mutable internal structures throughout the interpreter. The GIL allows one thread at a time to execute Python bytecode in an interpreter and protects access to much of that internal state.

That statement has boundaries:

  • It is about CPython, not a universal Python language rule.
  • It is per interpreter in modern CPython, not necessarily one lock for every interpreter in a process.
  • Native extensions can release the GIL around work that does not access Python objects.
  • Blocking operations commonly release the GIL.
  • The executing thread periodically yields so another runnable thread can acquire it.
  • The GIL protects interpreter integrity; it does not make a multi-step business operation correct.

CPython detail. In a normal GIL-enabled CPython build, one thread at a time executes Python bytecode in an interpreter. Python's language reference does not require every implementation to use this lock.

A helpful mental model is a workshop with one shared interpreter workbench. Threads may leave the bench to wait for supplies or use independent machinery. While one thread is away, another can use the bench. Pure Python CPU work keeps returning to the same single bench.

Measure the shape of the work

The following experiment runs four pure Python computations and four waits, first sequentially and then through a thread pool:

from concurrent.futures import ThreadPoolExecutor
from time import perf_counter, sleep


def cpu_work(n):
    total = 0
    for i in range(n):
        total += (i * i) % 97
    return total


def io_work(delay):
    sleep(delay)
    return delay


def elapsed(action):
    started = perf_counter()
    action()
    return perf_counter() - started


print(elapsed(lambda: [cpu_work(3_000_000) for _ in range(4)]))
print(elapsed(lambda: list(
    ThreadPoolExecutor(4).map(cpu_work, [3_000_000] * 4)
)))
print(elapsed(lambda: [io_work(0.15) for _ in range(4)]))
print(elapsed(lambda: list(
    ThreadPoolExecutor(4).map(io_work, [0.15] * 4)
)))

On our default CPython 3.14.7 build:

CPU sequential: 0.470s
CPU threads:    0.519s
wait sequential: 0.611s
wait threads:    0.155s

These numbers are not portable benchmarks. They demonstrate two mechanisms. Threads did not give the pure Python loop another bytecode workbench, and scheduling added overhead. Threads did overlap time spent in sleep(), reducing wall-clock time close to the longest individual wait.

Real I/O is noisier. DNS, connection pools, rate limits, server capacity, disk queues, and retries can dominate. The conclusion is not "use four threads." It is "measure a representative workload and identify where threads are runnable versus blocked."

Native code changes the answer

"CPU-bound" does not automatically mean "threads cannot help." Ask where the CPU instructions execute.

A compression library, image codec, database driver, regular-expression engine, or numeric library may release the GIL while doing substantial native work. Multiple Python threads can then execute those native sections in parallel. Other extensions retain the GIL. Some libraries start their own native thread pools, making an additional Python pool harmful through oversubscription.

The only reliable process is:

  1. Read the library's concurrency documentation.
  2. Check whether its expensive operation releases the GIL.
  3. Inspect configuration for native worker pools.
  4. Benchmark the complete workload, including data conversion and orchestration.

Do not infer GIL behavior merely because an API is implemented in C. Native code must deliberately release the lock when it can safely avoid Python objects.

The GIL does not remove data races

Suppose two threads update a balance:

balance = 100


def withdraw(amount):
    global balance
    if balance >= amount:
        balance -= amount

The rule "only one thread executes Python bytecode at a time" does not make the check and update one indivisible transaction. A thread switch can occur between operations. User-defined comparisons or methods can execute arbitrary Python. I/O or extension calls can release the GIL. The operation spans a read, a decision, and a write that must agree about shared state.

Protect the invariant explicitly:

from threading import Lock


balance = 100
balance_lock = Lock()


def withdraw(amount):
    global balance
    with balance_lock:
        if balance >= amount:
            balance -= amount
            return True
        return False

The lock communicates which operations form one state transition. It also remains meaningful on a free-threaded build.

Engineering rule. Do not use the GIL as your application lock. Synchronize invariants, not individual opcodes. Code whose correctness depends on today's accidental atomicity is fragile across refactors, implementations, and free-threaded execution.

Queues are often clearer than shared mutation. queue.Queue combines storage and synchronization for producer-consumer work. Immutability, partitioned ownership, database transactions, and message passing can remove shared-state races rather than merely lock them.

Free-threaded CPython is a different build

Starting with Python 3.13, CPython can be built with --disable-gil. Python 3.14 describes free threading as officially supported, while the default python.org build remains GIL-enabled. "Python 3.14" alone does not tell you which runtime mode is active.

Check both build support and current runtime state:

import sys
import sysconfig


supports_free_threading = (
    sysconfig.get_config_var("Py_GIL_DISABLED") == 1
)
gil_is_enabled = sys._is_gil_enabled()

print(supports_free_threading)
print(gil_is_enabled)
print(sys.flags.gil)

Our default build prints:

False
True
1

The Py_GIL_DISABLED configuration variable describes how CPython was built. sys._is_gil_enabled() describes the running process. A free-threaded build can still run with the GIL enabled through -X gil=1 or PYTHON_GIL=1.

An extension module that is not marked as free-threading compatible can also re-enable the GIL when imported, with a warning. This is why checking only the executable filename or ABI is insufficient for runtime diagnostics.

Removing one lock requires adding other coordination

A free-threaded interpreter is not CPython with synchronization deleted. PEP 703 required broad runtime changes:

  • biased and deferred reference-counting strategies;
  • immortal objects in selected cases;
  • a thread-safe allocator;
  • per-object locking for mutable containers;
  • optimistic read paths for important list and dictionary operations;
  • stop-the-world coordination during cyclic garbage collection.

Python 3.14's free-threading HOWTO says built-in containers such as lists, dictionaries, and sets use internal locks to protect against concurrent modifications in ways intended to resemble GIL-enabled safety. It also explicitly says Python has not historically guaranteed behavior for concurrent container modification.

Use threading.Lock or another synchronization primitive instead of treating internal container locks as an API. An internally uncorrupted dictionary can still contain a logically impossible combination of values.

Free-threaded builds also carry tradeoffs. Python 3.14 documents workload-dependent single-thread overhead, with pyperformance averages ranging from about 1 percent on macOS arm64 to 8 percent on x86-64 Linux. Memory use can increase because object headers, allocation, immortalization, and reference-counting behavior differ. Some objects may be reclaimed later than on a default build.

The benefit is substantial for suitable workloads: Python threads can execute Python code on multiple cores. The cost model, package compatibility, and synchronization requirements still need measurement.

Four ways to reach more than one core

Modern Python offers several mechanisms, none universally best.

Free-threaded threads

Threads share ordinary objects and have relatively cheap communication. They fit code already designed around threaded shared memory. They also expose races that the GIL may previously have hidden, and extension compatibility must be checked.

ProcessPoolExecutor

Processes have separate memory and separate interpreters, so default CPython's GIL is not shared. Arguments and results generally cross a serialization boundary. Startup, pickling, copying, and process memory can dominate small tasks. In Python 3.14, the default POSIX process start method changed away from fork; code that relies on fork semantics must request it explicitly and accept its risks.

from concurrent.futures import ProcessPoolExecutor


def square_sum(limit):
    return sum(i * i for i in range(limit))


if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        results = list(pool.map(square_sum, [4_000_000] * 4))
    print(results)

The __main__ guard matters because workers must import the main module safely. Functions and arguments must be picklable under the process-pool model.

InterpreterPoolExecutor

Python 3.14 adds concurrent.futures.InterpreterPoolExecutor. Workers run in threads, but each has an isolated interpreter and its own GIL, allowing multi-core execution in a default build.

Isolation is the defining tradeoff. Mutable Python objects cannot simply be shared between interpreters. Callables, arguments, and return values are serialized for executor tasks. Imports and global state belong separately to each interpreter. This can make concurrency easier to reason about, but it is not a transparent replacement for a thread pool.

Native parallelism

Libraries can release the GIL, use OpenMP or native thread pools, dispatch to GPUs, or vectorize loops in compiled code. This often wins for numeric workloads because less data crosses a Python-level worker boundary. It can also produce nested thread pools and severe oversubscription. Coordinate worker counts across layers.

Async I/O solves a different problem

asyncio does not bypass the GIL for CPU-heavy Python. It provides cooperative concurrency, typically in one thread, for tasks that spend much of their time waiting. Coroutines yield at explicit await points, which can make scheduling and cancellation more structured than arbitrary thread interleaving.

Choose async when the libraries are async-native and the application manages many concurrent waits. Choose threads when calling blocking APIs, integrating synchronous libraries, or using native operations that release the GIL. Moving a blocking call into asyncio.to_thread() still uses a thread; it does not transform the operation into nonblocking I/O.

A workload decision table

Use the shape of the work as a starting point:

  • Many blocking network or file operations with synchronous APIs: thread pool.
  • Many connections with an async-native stack: asyncio and structured task management.
  • Independent pure Python CPU tasks on default CPython: processes or isolated interpreters.
  • Pure Python CPU tasks on a compatible free-threaded stack: measure threads.
  • Large native numeric kernels: learn the library's own parallel model first.
  • Shared mutable state with strict invariants: explicit locks, ownership, or message passing in every build.
  • Tiny tasks: batch them before adding any executor; scheduling can cost more than the work.

This is a shortlist for experiments, not an architecture generator. Deployment constraints, memory, cancellation, failure isolation, debugging, and data-transfer volume can reverse an initial choice.

Benchmark without proving your assumption

Concurrency benchmarks are particularly easy to bias. Include:

  • exact Python version and whether the build is free-threaded;
  • whether the GIL is currently enabled;
  • platform and core count;
  • library versions and native thread settings;
  • worker count and task granularity;
  • warmup and repeated measurements;
  • serialization, startup, queueing, and result collection;
  • validation that each strategy computes the same result.

Measure wall time for end-to-end throughput and CPU time or utilization when investigating parallel execution. A faster wall time caused by overlapping waits is valuable even if CPU use stays low. High CPU utilization is not success if coordination makes the job slower.

Exercises: remove the folklore yourself

  1. Run the CPU/wait experiment with one, two, four, and eight workers. Explain the curve rather than naming the fastest point.
  2. Replace sleep() with a local HTTP server or repeated file reads. Identify which external bottleneck eventually limits scaling.
  3. Run the build-detection snippet on default and free-threaded CPython. Then import your extension-heavy dependencies and check whether the runtime GIL state changes.
  4. Write a deliberately unsafe check-then-update operation. Protect it first with a lock and then redesign it with a queue or single owner.
  5. Compare thread, process, and interpreter executors for tasks of increasing size. Find the point where worker overhead becomes worthwhile on your machine.

Keep this model

The GIL is a CPython runtime mechanism, not Python's definition of concurrency. In the default build it prevents multiple threads in one interpreter from executing Python bytecode simultaneously, but blocked threads and native code can release it. That makes threads useful for many waiting and extension-backed workloads while leaving pure Python CPU loops unable to scale across cores.

The GIL protects interpreter internals, not your multi-step invariants. Correct concurrent code still requires locks, ownership, transactions, or message passing.

Free-threaded CPython changes the CPU-parallelism boundary, not the need for engineering judgment. It is an alternative build with different compatibility, memory, performance, and synchronization tradeoffs. Detect the actual runtime, classify the work, and measure the complete system.

Primary sources