Subinterpreters
Subinterpreters
A subinterpreter is another Python runtime in the same process: its own imports, its own globals, and (since 3.12) its own GIL. Python 3.14 puts this on the boring path with concurrent.interpreters (PEP 734) and concurrent.futures.InterpreterPoolExecutor. Isolation feels like multiprocessing. Staying in one process feels like threading. The boring default is still threads for shared-memory I/O and processes for hard isolation. Reach for subinterpreters when you want CPU work in parallel without a process per worker, and you can pass only shareable (or pickleable, for the pool) values between them.
Mental model
interpreters.create()starts an idle interpreter.close()destroys it. Leaving one open prints aRuntimeWarningat exit.interp.call(fn, *args)switches to that interpreter in the current OS thread, runsfn, and returns the result. No extra thread appears unless you ask. Arguments and returns must be shareable (interpreters.is_shareable).interp.call_in_thread(fn, *args)does the same work on a new OS thread and returns athreading.Threadyoujoin(). The return value offnis not on theThreadobject — use a cross-interpreterQueue(interpreters.create_queue()) when you need the value back.interp.exec(source)runs statements in the subinterpreter’s__main__. Bind names withprepare_main(...)beforeexecif the code must see objects from outside.- Most values are not shareable for
call/prepare_main/ queues.None, bools, ints, floats, str, bytes, and tuples of those usually are.memoryviewand cross-interpreterQueueshare underlying data. Custom class instances, locks, open files, and closures that close over cells are not. Check withinterpreters.is_shareable(obj)before you design around a type. InterpreterPoolExecutoris aThreadPoolExecutorsubclass: each worker thread owns its own interpreter (and GIL). It pickles the callable and arguments (likeProcessPoolExecutor), so the contract is pickleable + mostly stateless — broader than strict shareability, but still not “any object.”- Subinterpreters do not share the GIL with each other. Combine them with threads (or the pool) for multi-core CPU work. They are not a security boundary: same process, same address space. Many third-party extension modules are not multi-interpreter-safe yet; the stdlib extension modules are.
Worked examples
Case 1: Create, call, close
Save as interp_call.py. A plain function and an int cross the boundary. Print both interpreter ids so you see they are not the same runtime.
# interp_call.py
from concurrent import interpreters
def square(n):
return n * n
def main():
interp = interpreters.create()
try:
print(interp.call(square, 12))
print("main", interpreters.get_main().id)
print("sub", interp.id)
finally:
interp.close()
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python interp_call.pyOutput:
144
main 0
sub 1
call is switch-and-run on this thread. Parallelism needs Case 3 or Case 4.
Case 2: Isolation with a queue
Save as interp_isolate.py. The subinterpreter owns counter. The main interpreter never defines that name. A cross-interpreter queue carries the result back.
# interp_isolate.py
from concurrent import interpreters
def main():
q = interpreters.create_queue()
interp = interpreters.create()
try:
interp.prepare_main(q=q)
interp.exec(
"""
counter = 0
for _ in range(5):
counter += 1
q.put(counter)
"""
)
print("from sub:", q.get())
print("main has counter:", "counter" in globals())
finally:
interp.close()
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python interp_isolate.pyOutput:
from sub: 5
main has counter: False
prepare_main puts q into the sub’s __main__ before exec. The integer on the queue is a copy the main side can read. The sub’s counter never leaked into main globals.
Case 3: InterpreterPoolExecutor
Save as interp_pool.py. Same shape as a process pool: map a pure function over ints. Workers are interpreters in threads, not OS processes. The executor pickles dig and each argument.
# interp_pool.py
from concurrent.futures import InterpreterPoolExecutor
def dig(n):
total = 0
for i in range(n):
total += i * i
return total
def main():
with InterpreterPoolExecutor(max_workers=2) as pool:
print(list(pool.map(dig, [200, 300, 400])))
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python interp_pool.pyOutput:
[2646700, 8955050, 21253400]
Keep dig stateless and its arguments pickleable. That is the whole contract. For a one-off shareable call, Case 1 is enough. For a pile of CPU chunks, use the pool.
Case 4: Thread + queue for a result
Save as interp_queue_thread.py. call_in_thread returns a Thread, not the function’s return value. Put the answer on a queue, join, then get.
# interp_queue_thread.py
from concurrent import interpreters
def put_square(n, queue):
queue.put(n * n)
def main():
q = interpreters.create_queue()
interp = interpreters.create()
try:
t = interp.call_in_thread(put_square, 9, q)
t.join()
print(q.get())
finally:
interp.close()
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python interp_queue_thread.pyOutput:
81
The queue is one of the few mutable types meant to cross interpreters. Prefer it over inventing shared objects.
The trap
Save as interp_share_fail.py. Pass a threading.Lock or a closure that closes over a cell. The call fails with NotShareableError. An int still works.
# interp_share_fail.py
from concurrent import interpreters
import threading
def lock_name(lock):
return type(lock).__name__
def double(n):
return n * 2
def main():
interp = interpreters.create()
try:
try:
print(interp.call(lock_name, threading.Lock()))
except interpreters.NotShareableError as exc:
print(type(exc).__name__)
print(interp.call(double, 7))
print("shareable int:", interpreters.is_shareable(7))
print("shareable Lock:", interpreters.is_shareable(threading.Lock()))
finally:
interp.close()
if __name__ == "__main__":
main()Run:
uv run --python 3.14 python interp_share_fail.pyOutput:
NotShareableError
14
shareable int: True
shareable Lock: False
Check is_shareable before you design a pipeline around custom objects for call / queues. If the value is not shareable, copy a plain type across a queue, use the pool’s pickle path deliberately, or stay on one interpreter.
The boring rule
- Prefer threads when workers share memory and wait on I/O. Prefer processes when you need hard isolation or a library that is not interpreter-safe. Prefer subinterpreters when CPU work should fan out in-process and values are shareable (or pickleable for the pool).
- Always
close()(or useInterpreterPoolExecutor’s context manager). Idle interpreters are not free. - For
call/prepare_main/ queues: pass ints, strings, bytes, and simple tuples; usecreate_queue()for results fromcall_in_thread. - Keep callables stateless module-level functions. No closures over cells, no locks, no open files as arguments.
- Do not treat subinterpreters as a sandbox. Same process can still see the same memory through a bad extension module.
Try this
- In
interp_call.py, callsquaretwice with3and4. Confirm both results print beforeclose. - In
interp_isolate.py,q.put(("ok", counter))and print the tuple on the main side. - In
interp_pool.py, raiseRuntimeError("burnt")insidedigforn == 300. Catch the failure frompool.map. - In
interp_share_fail.py, replace the lock with a nested function (def outer(): x = 1; def inner(n): return n + x; return inner) and showNotShareableErrorforinterp.call(outer(), 3).