On this page
Description
For thirty years, the GIL meant one thing for Python programmers: threads can't compute in parallel, so CPU-heavy work goes to processes. Python 3.13 and 3.14 changed that picture with two new options. The free-threaded build is a version of Python with no GIL at all, where threads really do run on several cores at once. Subinterpreters let one process run several separate Python interpreters side by side, each with its own GIL. This lesson explains both: what they give you, what they cost, and the old bugs they can wake up.
Here's the kind of bug we mean. A service has counted requests like this for years:
served = 0
def handle(request):
global served
served += 1
...
It never lost a count in production. Then the team runs its CPU-heavy endpoints on the free-threaded build, and the totals stop adding up. Nothing in the code changed - so what did? By the end of the first section you'll know.
We'll go from simple to more technical:
- The free-threaded build: what it is, how to tell which build you're on, and its status.
- What stays safe without the GIL, and what breaks.
- C extensions and the GIL coming back.
- Subinterpreters and
InterpreterPoolExecutor. - Choosing between threads, subinterpreters and processes.
The free-threaded build
PEP 703 made the GIL optional when Python is built. The result is a separate interpreter, usually installed as python3.14t (the t is for "threaded"), next to the normal python3.14:
- In Python 3.13 it was experimental.
- In Python 3.14 it's officially supported (PEP 779), and much faster than in 3.13 - the release notes put the single-threaded slowdown at about 5-10%.
- It's not the default. Plain
python3still has the GIL, and you can't switch the GIL off while a normal build is running.
To check which one you're on:
import sys, sysconfig
sys._is_gil_enabled() # True on the normal build
sysconfig.get_config_var("Py_GIL_DISABLED") # 1 if Python was built free-threaded, 0 if not
On the free-threaded build, CPU-bound threads finally run in parallel: four threads crunching numbers can use four cores, with no processes and no pickling.
What stays safe, and what breaks
Without the GIL, Python still protects its built-in objects from being corrupted. Lists, dicts and sets lock themselves internally, so items.append(x) or cache[key] = value from many threads at once still leaves a valid list or dict.
What the free-threaded build doesn't protect - and the normal build never did either - is a sequence of steps. served += 1 is three steps: read served, add one, write it back. On the normal build, the GIL almost never switches threads in the middle of such a short sequence, so the race stays hidden for years. Without the GIL, two threads really do run the same line at the same moment, both read 41, both write 42, and a count is lost.
That's the answer to the puzzle: the bug was always there. The GIL just made it rare. The fix is the same as in the threading skill:
lock = threading.Lock()
def handle(request):
global served
with lock:
served += 1
Before moving code to the free-threaded build, look for every read-then-write and check-then-act on shared data, and lock it.
C extensions can bring the GIL back
Libraries written in C - NumPy, database drivers, image libraries - were written assuming the GIL exists. Each one must declare that it's safe without it. If the free-threaded build imports a C extension that hasn't, it turns the GIL back on for the whole process and prints a RuntimeWarning. Your code keeps working, but the parallel speed-up silently disappears. (-X gil=0 or PYTHON_GIL=0 forces the GIL off anyway, at your own risk.)
This is the main reason the free-threaded build isn't the default yet: the ecosystem is still being updated. Check that your dependencies publish free-threaded wheels (their file names include cp314t) before planning around it.
Subinterpreters
The second option works on the normal build. One process can run several interpreters, each with its own modules, its own global variables and - since Python 3.12 - its own GIL. Python 3.14 added an easy way to use them:
from concurrent.futures import InterpreterPoolExecutor
with InterpreterPoolExecutor(max_workers=4) as pool: # like ThreadPoolExecutor, but each worker is an interpreter
totals = list(pool.map(score, batches))
What to know:
- They run Python code in parallel. Measured on an 8-core laptop, four CPU-bound tasks took 0.49 s on four threads, 0.12 s on four subinterpreters, and 0.14 s on four processes.
- They share no objects. Arguments and results are copied between interpreters, much like with processes. The function you submit must be one that can be found by name (defined at the top level of a module), or a self-contained function that uses no global names.
- Each worker keeps its own globals between tasks. A function that incremented a module-level counter returned
[1, 2, 3]over three tasks on a one-worker pool - while the main interpreter's counter stayed0. - They start faster than processes - a pool's first task took about 24 ms, against about 57 ms for a process pool with
spawn- and live in one process. - C extensions must support them too, and many can't yet be imported into a subinterpreter.
There's also a lower-level module, concurrent.interpreters, for creating interpreters by hand and passing data through queues.
Choosing, as of Python 3.14
| The work | Normal build | Free-threaded build |
|---|---|---|
| Lots of waiting on I/O | threads or asyncio | threads or asyncio |
| CPU-heavy pure Python | processes, or InterpreterPoolExecutor | threads |
| CPU-heavy NumPy and similar | threads (their C code releases the GIL) | threads |
| Must survive a crash in a C library | processes | processes |
Both new options are young. For most services today, processes remain the safe default for CPU-heavy Python; try the new options where their extensions are supported and measure.
Common mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
| Thinking Python 3.13/3.14 removed the GIL | expecting speed-ups that don't come | the free-threaded build is separate: python3.14t |
Moving unlocked += or check-then-act code to free-threading | lost updates, double work | lock every compound step on shared data |
Ignoring the RuntimeWarning about the GIL | the GIL is back on; no speed-up | use extensions that support free-threading |
| Treating subinterpreters like threads | they don't see the main interpreter's objects | pass data in, return results |
| Submitting a function that uses globals but isn't importable | NotShareableError | define it at module level |
Check yourself
Try to answer before opening each one. Recalling beats re-reading.
1. Why did the request counter start losing counts on the free-threaded build?
Show answer
served += 1 is a read, an add and a write. On the normal build, the GIL almost never switches threads in the middle, so the race was hidden. Without the GIL, two threads can do it at the same moment and one update is lost. Put a lock around it.
2. Is the GIL gone in Python 3.14?
Show answer
No. The normal build still has it. A separate free-threaded build without a GIL is officially supported in 3.14, but it's not the default.
3. What does InterpreterPoolExecutor give you on the normal build that ThreadPoolExecutor doesn't?
Show answer
Each worker is a separate interpreter with its own GIL, so CPU-heavy Python runs in parallel - at the cost of sharing no objects with the main interpreter.
Key takeaways
- The free-threaded build (
python3.14t) has no GIL; it's supported in 3.14 but not the default. Check withsys._is_gil_enabled(). - Built-in objects stay internally safe without the GIL; your multi-step operations don't - lock them.
- One C extension that doesn't support free-threading turns the GIL back on.
- Subinterpreters run in parallel on the normal build, share no objects, and keep their own globals.
- Processes remain the safest choice for CPU-heavy work until your dependencies support the new options.