Python 3.14 gives application code a new way to work directly with multiple interpreters in one process.
The concurrent.interpreters module exposes a high-level API for creating interpreters, running code inside them, and communicating through cross-interpreter queues. It sits below InterpreterPoolExecutor: instead of submitting independent jobs to a ready-made pool, you own the interpreter lifecycle and decide how work reaches each isolated execution context.
That extra control is useful, but it also removes several conveniences an executor normally provides. A subinterpreter is not a lightweight thread with shared globals, and creating one does not automatically create concurrency.
The right mental model is closer to an isolated component inside the same process.
Start with isolation, not parallelism
Create an interpreter with concurrent.interpreters.create():
from concurrent import interpreters
worker = interpreters.create()
try:
worker.exec("value = 40 + 2")
finally:
worker.close()The new interpreter has its own runtime state, including its own __main__ module, imports, and builtins.
Code executed with exec() uses that interpreter’s __main__ namespace. State created there can therefore persist across later executions in the same interpreter:
from concurrent import interpreters
worker = interpreters.create()
try:
worker.exec("counter = 0")
worker.exec("counter += 1")
worker.exec("print(counter)")
finally:
worker.close()This is fundamentally different from calling a function twice in the main interpreter. The worker owns a separate namespace and import state.
That separation is the feature.
call() does not create another thread
The direct API can be surprising because an interpreter and a thread are separate concepts.
This call:
result = worker.call(pow, 2, 10)runs the callable in the target interpreter, but it switches execution context in the current thread. It does not make the operation concurrent with the caller.
If the operation takes five seconds, the calling thread is occupied for those five seconds.
This distinction matters when evaluating a design. Creating several interpreters and calling them sequentially does not create parallel work:
left = interpreters.create()
right = interpreters.create()
try:
a = left.call(pow, 2, 20)
b = right.call(pow, 3, 12)
finally:
left.close()
right.close()The two calls still happen one after the other.
Interpreters provide isolated execution contexts. Threads provide concurrent execution.
Use call_in_thread() when you need concurrent execution
Python 3.14 provides Interpreter.call_in_thread() as a convenience for combining the two.
from concurrent import interpreters
def crunch(limit):
total = 0
for value in range(limit):
total += value * value
return total
left = interpreters.create()
right = interpreters.create()
try:
t1 = left.call_in_thread(crunch, 5_000_000)
t2 = right.call_in_thread(crunch, 5_000_000)
t1.join()
t2.join()
finally:
left.close()
right.close()Each helper starts a new OS thread and runs the callable using its target interpreter.
In normal GIL-enabled CPython, sufficiently isolated interpreters have separate GILs. Combining multiple interpreters with multiple threads can therefore execute Python code on multiple CPU cores.
That is the same broad capability used by InterpreterPoolExecutor, but the direct API leaves scheduling, result transport, shutdown, and error policy to your application.
Prefer the executor when jobs are independent
Direct interpreter management should not be the default merely because it is lower level.
For a stream of ordinary independent callables, InterpreterPoolExecutor is usually easier:
from concurrent.futures import InterpreterPoolExecutor
with InterpreterPoolExecutor(max_workers=4) as executor:
results = list(executor.map(transform, items))The executor already owns worker threads, interpreter reuse, futures, result collection, and shutdown.
Reach for concurrent.interpreters when the interpreter itself is part of the architecture.
Examples include:
- a long-lived isolated component with private module state;
- an actor-like worker with a dedicated message loop;
- explicit interpreter setup before accepting work;
- experiments with interpreter lifecycle and compatibility;
- systems that need a custom communication protocol rather than a generic future per call.
If none of those properties matter, the executor is a valuable abstraction rather than overhead to remove.
Move messages through a cross-interpreter queue
Isolation means ordinary mutable Python objects are not a shared heap between interpreters.
For explicit communication, Python 3.14 provides interpreters.create_queue():
from concurrent import interpreters
messages = interpreters.create_queue()
messages.put("hello")
print(messages.get())The queue implements the familiar queue.Queue interface, but its purpose is different: it can cross interpreter boundaries.
A long-lived worker can receive commands from such a queue:
from concurrent import interpreters
def consume(inbox):
while True:
message = inbox.get()
if message is None:
return
print("worker received:", message)
worker = interpreters.create()
inbox = interpreters.create_queue()
try:
thread = worker.call_in_thread(consume, inbox)
inbox.put("first")
inbox.put("second")
inbox.put(None)
thread.join()
finally:
worker.close()The sentinel is part of the application protocol. The interpreter API does not invent a worker shutdown message for you.
That is an important design principle: define message ownership and lifecycle explicitly.
Treat message schemas like process boundaries
Passing data between interpreters is not the same as handing another function a reference to an arbitrary mutable object.
Most transferred objects are copied, commonly through pickle. Several immutable built-in values can be transferred more efficiently, while a small set of types such as cross-interpreter queues and memory views have special sharing behavior.
Design messages accordingly.
Instead of passing a rich object graph with open resources:
inbox.put(application_service)prefer a small data message:
inbox.put(("resize-image", image_id, 1280, 720))Then let the receiving interpreter construct or own the resources it needs.
This reduces coupling between interpreter-local state and makes serialization failures easier to diagnose.
It also makes the protocol easier to test independently from concurrency.
Do not assume mutable globals stay synchronized
Suppose the main interpreter has this module-level dictionary:
settings = {"mode": "safe"}A worker interpreter does not automatically observe later mutations to that object.
That is useful because it prevents accidental sharing, but it means configuration propagation must be deliberate.
Send a new configuration message:
inbox.put(("config", {"mode": "fast"}))or create a new immutable snapshot and transfer it through the boundary.
Do not build correctness around the idea that assigning a global in one interpreter updates a similarly named global in another.
The namespaces are isolated.
Initialize each interpreter deliberately
A long-lived worker often needs setup before it handles real work.
prepare_main() can bind initial names in the target interpreter’s __main__ namespace:
worker = interpreters.create()
try:
worker.prepare_main(worker_name="thumbnail-1")
worker.exec('print(f"starting {worker_name}")')
finally:
worker.close()You can also perform imports and initialization through exec() or a setup callable.
Keep this phase explicit. It is a good place to validate that required modules can actually run under multiple interpreters before the worker becomes part of the service.
For expensive libraries, measure initialization cost rather than assuming interpreter creation is free.
Audit extension modules before production use
Pure Python isolation is only part of the compatibility story.
Not every third-party package is ready to run safely in multiple interpreters. Native extension modules may contain process-global state or assumptions that were historically reasonable when applications used only one interpreter.
A package importing successfully in the main interpreter does not prove that it is safe in several subinterpreters at once.
Test the actual dependency set:
from concurrent import interpreters
worker = interpreters.create()
try:
worker.exec("import your_native_dependency")
finally:
worker.close()Then go further than import tests. Exercise initialization, concurrent calls, repeated interpreter creation and destruction, and error paths.
If a dependency documents that it does not support subinterpreters, treat that as an architectural constraint rather than something to work around casually.
Isolation is not a security sandbox
Subinterpreters live in the same operating-system process.
The Python runtime tries to isolate interpreter state, but that is not a security boundary against hostile code. Native extensions operate inside the same address space and can violate assumptions that pure Python code would normally preserve.
Do not run untrusted plugins in a subinterpreter and claim they are sandboxed from the host application.
For adversarial code, use an isolation mechanism designed as a security boundary, such as a separate restricted process, container, or stronger platform sandbox appropriate to the threat model.
Subinterpreters are an application architecture and concurrency feature, not a privilege boundary.
Separate worker errors from transport errors
Code running in another interpreter can fail.
The module exposes ExecutionFailed for uncaught exceptions raised by executed code, with an excinfo snapshot describing the remote failure.
Operationally, distinguish at least three classes of failure:
- the worker function rejected or failed on a valid message;
- a value could not cross the interpreter boundary;
- the interpreter itself became unavailable or failed lifecycle operations.
Do not collapse all of these into a generic worker failed log line.
For an actor-like service, include a request identifier in every message and return structured success or error responses through a separate queue. That gives the caller enough context to match a failure to the command that caused it.
Build request and response channels explicitly
A useful pattern is to give a worker an input queue and an output queue:
from concurrent import interpreters
def serve(requests, responses):
while True:
request = requests.get()
if request is None:
return
request_id, value = request
try:
result = value * value
except Exception as exc:
responses.put((request_id, "error", repr(exc)))
else:
responses.put((request_id, "ok", result))
worker = interpreters.create()
requests = interpreters.create_queue()
responses = interpreters.create_queue()
try:
thread = worker.call_in_thread(serve, requests, responses)
requests.put((1, 12))
requests.put((2, 25))
print(responses.get())
print(responses.get())
requests.put(None)
thread.join()
finally:
worker.close()This resembles an actor or CSP-style design more than shared-memory threading.
The benefit is not merely performance. Ownership becomes visible: the worker owns its local state, callers send commands, and results cross a narrow protocol boundary.
Put backpressure into the protocol
A worker can consume messages more slowly than producers create them.
If the design allows an unbounded backlog, memory can grow even though the worker itself is healthy.
Use bounded queue behavior where appropriate, or enforce admission limits before placing work into the channel. Handle full-queue behavior as a normal overload condition rather than an impossible exception.
Backpressure policy should answer concrete questions:
- Should producers block?
- Should low-priority work be rejected?
- Is there a deadline after which queued work is useless?
- Can duplicate work be coalesced?
- What metric exposes queue saturation?
Interpreter isolation does not solve overload. It only changes where the queue sits.
Design shutdown before startup
Long-lived workers need a deterministic stop path.
A safe sequence is usually:
- stop accepting new commands;
- send an explicit shutdown message;
- allow the worker loop to finish or drain according to policy;
- join the worker thread;
- close the interpreter.
Avoid closing an interpreter while application logic still expects it to execute work.
Also decide what happens when shutdown times out. A same-process worker cannot be treated exactly like an external process that can simply be killed without consequences to the parent.
If hard fault containment is a requirement, process isolation may be the better architecture.
Remember that process-global resources still exist
Interpreter-local Python state does not imply that every resource is interpreter-local.
The workers still share one process identity and operating-system environment. Native libraries can have process-wide state. File-system paths, network endpoints, signals, and other external resources can create coupling even when Python module dictionaries are isolated.
Make ownership explicit for resources such as:
- files being written;
- local database connections;
- sockets and listening ports;
- temporary directories;
- native library configuration;
- metrics exporters and process-wide instrumentation.
Two interpreters independently believing they own one external resource can produce the same classes of bugs as two processes doing so.
Measure the architecture you actually deploy
Multi-core Python execution is attractive, but interpreter overhead and message transfer still matter.
Benchmark complete work units rather than a synthetic loop alone. Include:
- interpreter startup and warm-up when relevant;
- module imports;
- serialization or copying costs;
- queue latency;
- worker utilization;
- result transfer;
- memory per interpreter;
- shutdown and replacement cost.
Small jobs can lose to coordination overhead. Large jobs may benefit substantially from parallel execution.
Also compare against ThreadPoolExecutor, InterpreterPoolExecutor, and ProcessPoolExecutor for the same workload. The direct interpreter API should earn its complexity through a requirement those higher-level tools do not satisfy.
Test isolation as a property
A good test suite should prove more than successful arithmetic.
Create tests where one interpreter mutates module state and verify another interpreter does not accidentally depend on it. Exercise repeated creation and destruction. Run native dependencies concurrently. Send malformed protocol messages. Fill queues. Trigger worker exceptions. Shut down while work is outstanding.
Test lifecycle leaks too. A service that replaces workers after failures should not accumulate threads, interpreters, file descriptors, or library state over time.
Stress tests are especially valuable because compatibility bugs in extension modules may only appear when several interpreters execute simultaneously.
Know what Python 3.14 changed
concurrent.interpreters is new in Python 3.14.
That means code using it needs an explicit runtime requirement or compatibility path. Do not silently fall back to ordinary threads if the architecture depends on interpreter isolation or separate GILs; that changes both semantics and performance.
A simple startup check can fail clearly:
import sys
if sys.version_info < (3, 14):
raise RuntimeError("this worker architecture requires Python 3.14+")For a library supporting several Python versions, keep the interpreter-specific implementation behind a capability boundary and document what alternative behavior actually means.
Use direct interpreters when isolation is part of the design
Python 3.14 makes subinterpreters a practical application-level primitive instead of something most Python developers encounter only through runtime internals.
The most important detail is not that they can unlock multi-core execution. It is that they give one process multiple isolated Python execution contexts with explicit communication between them.
call() switches execution context without creating concurrency. call_in_thread() combines an interpreter with a new thread. Cross-interpreter queues provide a message-passing boundary. Mutable application state should remain owned by one interpreter instead of being treated as implicitly shared. Native extensions need compatibility testing, and same-process isolation must never be confused with a security sandbox.
When you simply need parallel independent function calls, InterpreterPoolExecutor remains the easier tool. When you need long-lived isolated components, custom message protocols, or direct lifecycle control, concurrent.interpreters gives Python 3.14 a lower-level building block with unusually clear architectural boundaries.