“Subinterpreters remove the GIL” will be the most-repeated wrong statement about Python 3.14, and it is wrong in a specific way worth being precise about.
PEP 684 gave each subinterpreter its own GIL. There is still a GIL; there are now several, one per interpreter, and because each interpreter has its own object space they do not contend. That is genuine parallelism, and it is not the same claim. Free-threading — PEP 703 — is the one that removes the GIL, and it is a different build with different trade-offs.
What it costs, measured
10-core arm64, CPython 3.14.6:
| Operation | Cost |
|---|---|
| start a thread | 0.03 ms |
os.fork() |
0.87 ms |
| forkserver child | 1.40 ms |
| create + close one interpreter | 6.1 ms |
spawn child |
26.2 ms |
So an interpreter is roughly 200x a thread and about 4x a forkserver child — cheaper than spawn, dramatically more expensive than the thread it superficially resembles.
Memory: twenty live interpreters is about +69 MB RSS, roughly 3.4 MB each. And once running, the numbers are fine — interp.exec("pass") is about 5.8 microseconds, and import json in a fresh interpreter about 0.6 ms.
The conclusion is not subtle: pool them, never create per task. InterpreterPoolExecutor exists precisely so you do not have to think about this, and the moment you find yourself writing interpreters.create() inside a loop you have reinvented the slowest possible design.
💡Your service handles requests that spend 40 ms in pure-Python parsing. You move that parsing into a subinterpreter per request to escape the GIL, and throughput gets *worse* under load, not better. The parsing itself, when you time it inside the interpreter, still takes 40 ms. What are you paying for, and roughly how much? click to reveal
Two costs, and the second one is the one people miss.
The obvious one: 6.1 ms to create and close an interpreter, on top of 40 ms of work — a 15% tax before anything else happens. That alone should have been a warning, but it does not explain a throughput regression.
The one that does: a fresh interpreter has an empty module cache. Everything your parser imports gets imported again — not just resolved from sys.modules, but executed. import json alone is about 0.6 ms; a parser that pulls in re, datetime, decimal and a couple of your own modules is easily 10-30 ms of import work per request, and if it touches anything large the number goes much higher. You have turned a 40 ms operation into 40 ms of work plus 6 ms of setup plus an import cascade, on every request.
It also multiplies memory. Every concurrent request now holds its own ~3.4 MB baseline plus its own copies of every imported module, so peak RSS scales with concurrency in a way it did not before, which is what starts making things worse rather than merely slower.
The fix is the one the numbers imply: a fixed pool of interpreters created at start-up, each of which imports the parser once, reused across requests. InterpreterPoolExecutor(max_workers=N) does exactly this. The per-request cost drops to the exec call — microseconds — and the imports are paid N times at boot instead of once per request.
The general shape of the mistake is worth naming, because it recurs: a subinterpreter looks like a thread in the API and behaves like a process in its cost model. Every intuition you have about “just spin one up” comes from threads and is wrong here.
CPython’s own list of caveats
The documentation is unusually candid, and all four items are real:
- Start-up is unoptimised. The 6.1 ms above is not a floor anyone is defending; it is where the work has got to.
- Each interpreter uses more memory than necessary. Same.
-
There are few options for truly sharing data.
memoryviewcrosses without copying.int,float,bool,bytes,str,None, tuples of those, andQueueare shareable.listanddictare copied via pickle — so a large dict crossing the boundary costs exactly what it would cost to a process. -
Many PyPI extension modules are not yet multi-interpreter compatible. Every stdlib extension module is. Your dependencies may not be, and the failure is an
ImportErrorin the subinterpreter, not at your process start-up. Checkpy-free-threading.github.io/tracking/— the compatibility work overlaps heavily with free-threading’s.
The three things they are not
Not a security boundary. The docs say so explicitly: interpreters are “not strictly isolated at the memory level”. Do not run untrusted code in one. If that is your requirement, you want a process, a container, or a sandbox — in that order of strength.
Not a fault boundary. A segfault, an os._exit(), or a C-level abort in one interpreter kills the whole process — every other interpreter with it. This is the single largest operational difference from a process pool, where one worker dying costs you one worker. If your workload includes a C extension you do not fully trust with malformed input, that trade is not obviously in your favour.
Not fork-compatible. os.fork() raises RuntimeError inside a subinterpreter, and has since 3.8.
Where they genuinely win
Against a process pool, on the specific axes that matter:
-
No
__main__re-import, so no unguarded-top-level-code class of bug. - No pickling of the function — you send source or a shareable, not a qualified name that must resolve in another interpreter’s import system.
- No separate memory image, no fork hazards, no zombie processes, no resource tracker.
-
Cheaper transport than a pipe for shareable types, and
memoryviewcrosses without a copy at all. - 4.25x on the benchmark against a process pool’s 3.96x — modest, and real.
The honest summary: subinterpreters are a better process pool for in-process work, not a better thread pool. If your current answer is ProcessPoolExecutor and your payloads are small and your workers long-lived, they are worth measuring. If your current answer is ThreadPoolExecutor because your work is I/O or already releases the GIL, they will make things worse.