Intermediate

You have a CPU-bound task — hashing a million records, running a batch of regex over large files, or computing something numerically intensive — and you want to run it in parallel. You reach for threads, but the GIL blocks you. You reach for multiprocessing, but spawning a new process for each chunk feels heavy, your workers need to pickle everything, and startup time starts to matter. There is a third option that Python has quietly been building toward: subinterpreters.

Python 3.14 ships concurrent.interpreters as a stable public API (formalising PEP 734). A subinterpreter is an independent Python runtime living inside the same OS process. Each one has its own Global Interpreter Lock, its own module namespace, and its own memory. You run subinterpreters in threads, and because each interpreter has its own GIL, they execute Python bytecode in true parallel — no fighting over a shared lock. You get multiprocessing-grade parallelism with threading-grade process overhead. No forking. No pickling your entire call graph.

This article covers everything you need to use subinterpreters effectively: creating and closing interpreters, running code and callables inside them, passing data back via Queue, a performance comparison against threads and processes, and a real-world example that distributes a CPU workload across all available cores using nothing but the standard library.

Python Subinterpreters: Quick Example

Here is the minimal pattern: create two subinterpreters, run a CPU-bound function inside each one in parallel threads, then collect the results via a shared queue. Each interpreter runs its own GIL and can burn a full CPU core independently.

# quick_subinterpreters.py
# Requires Python 3.14+
from concurrent import interpreters
import threading
import queue

results = queue.Queue()

def worker(result_q, value):
    # This function runs inside a subinterpreter in a dedicated thread.
    # It does CPU-bound work: summing squares up to `value`.
    total = sum(i * i for i in range(value))
    result_q.put(total)

def run_in_interp(interp, fn, q, value):
    interp.call(fn, q, value)

interp1 = interpreters.create()
interp2 = interpreters.create()

t1 = threading.Thread(target=run_in_interp, args=(interp1, worker, results, 500_000))
t2 = threading.Thread(target=run_in_interp, args=(interp2, worker, results, 500_000))

t1.start()
t2.start()
t1.join()
t2.join()

interp1.close()
interp2.close()

total1 = results.get()
total2 = results.get()
print(f"Interpreter 1 result: {total1}")
print(f"Interpreter 2 result: {total2}")

Output:

Interpreter 1 result: 41666416666750000
Interpreter 2 result: 41666416666750000

The key things happening here: interpreters.create() spins up a new independent Python runtime. interp.call(fn, ...) runs that function inside the interpreter — the function and its arguments must be safe to cross interpreter boundaries (more on this in later sections). The threads run concurrently, and because each interpreter has its own GIL, both actually execute Python bytecode at the same time. The result queue is a regular queue.Queue shared between the main thread and the worker threads (the queue lives in the main interpreter’s memory; only the return value needs to cross the boundary).

What Are Subinterpreters and Why Use Them?

A Python subinterpreter is a second (or third, or tenth) instance of the Python runtime running inside the same OS process. Think of the main interpreter as the head chef running a kitchen. A subinterpreter is another fully equipped chef station — its own knives, its own cutting board, its own recipes (modules), its own sense of time (GIL). The two stations share the same kitchen walls (the OS process), but they do not share knives, and they do not have to wait for each other to pick up a tool before they can start chopping.

Before Python 3.12, subinterpreters existed but were experimental — they still shared a single GIL, so you could not get true parallelism. Python 3.12 made the GIL per-interpreter under the experimental --enable-experimental-jit build. Python 3.13 stabilised this. Python 3.14 shipped concurrent.interpreters as a first-class public module, giving you a clean API without touching CPython internals.

Use the table below to decide which parallelism tool fits your situation:

ScenarioThreadsmultiprocessingSubinterpreters
I/O-bound work (network, disk)Best fitOverkillOverkill
CPU-bound, small dataGIL blocks youWorks, high overheadBest fit
CPU-bound, large data transferGIL blocks youBest fit (shared mem)Good (crossing boundary costs)
Need separate OS processNoYesNo (same process)
Spawn time / overheadMinimalHigh (fork/exec)Low (in-process)
Crash isolationNoneFullPartial (interpreter crash may affect process)
Shared mutable stateEasy (risky)Explicit (Manager/pipes)Explicit (Queue/channels)
Python 3.14+ requiredNoNoYes (public API)

The sweet spot for subinterpreters is CPU-bound work where startup overhead matters and your data fits in simple types (ints, strings, bytes, small dicts). If your chunks are gigabytes of NumPy arrays that need to be transferred, multiprocessing shared memory is a better match. If all your work is waiting on sockets or disk, use asyncio or threads.

Cartoon character at whiteboard diagramming parallel execution lanes
Two interpreters. Two GILs. One process. This is what the threading docs never told you.

Creating and Closing Interpreters

The concurrent.interpreters module gives you a small, focused API. You create interpreters at the start of your work, use them, and then close them explicitly to release resources. Unlike threads or processes, subinterpreters are not automatically cleaned up when they go out of scope — you must call close() or use a context manager pattern.

# create_close.py
from concurrent import interpreters

# Create a new interpreter -- spins up a fresh Python runtime in-process
interp = interpreters.create()
print(f"Created interpreter with ID: {interp.id}")

# List all currently active interpreters (includes the main one)
all_interps = interpreters.list_all()
print(f"Active interpreters: {[i.id for i in all_interps]}")

# Get the current (main) interpreter
main = interpreters.get_current()
print(f"Main interpreter ID: {main.id}")

# Close the subinterpreter when done -- releases all its resources
interp.close()

# Verify it is gone
after = interpreters.list_all()
print(f"After close: {[i.id for i in after]}")

Output:

Created interpreter with ID: 1
Active interpreters: [0, 1]
Main interpreter ID: 0
After close: [0]

The main interpreter is always ID 0 and cannot be closed. Every subinterpreter you create gets a unique incrementing ID. Always call interp.close() when you are done — leaking subinterpreters wastes memory and file descriptors, and in long-running services this accumulates. If you want automatic cleanup, wrap interpreter creation in a try/finally block or a small context manager helper.

# safe_interp.py
from concurrent import interpreters
from contextlib import contextmanager

@contextmanager
def managed_interpreter():
    """Context manager that ensures the interpreter is closed on exit."""
    interp = interpreters.create()
    try:
        yield interp
    finally:
        interp.close()

with managed_interpreter() as interp:
    print(f"Using interpreter {interp.id}")
    # Do work here

print("Interpreter closed automatically")

Output:

Using interpreter 1
Interpreter closed automatically

The context manager pattern is the idiomatic way to use subinterpreters in application code. It is the same discipline as using open() with with — the resource is always released, even if an exception is raised inside the block.

Running Code in a Subinterpreter

There are two ways to run code inside a subinterpreter: passing a string of source code to exec(), or passing a callable to call(). Each has its use case, and understanding both helps you pick the right approach for your situation.

exec(): Running a Code String

interp.exec(src) compiles and runs a string of Python source code inside the subinterpreter. The code sees its own empty namespace — no variables from the calling interpreter leak in. This is useful for running standalone scripts or setting up a subinterpreter’s state before calling functions inside it.

# exec_example.py
from concurrent import interpreters

interp = interpreters.create()

# The subinterpreter has its own module namespace
interp.exec("""
import sys
import os

version = sys.version.split()[0]
pid = os.getpid()  # Same PID as the main interpreter -- same OS process
print(f"Subinterpreter running Python {version} in PID {pid}")
""")

interp.close()

Output:

Subinterpreter running Python 3.14.0 in PID 12345

Notice that os.getpid() returns the same PID as the main interpreter. Subinterpreters live inside the same OS process — this is what makes them cheaper to create than processes. Each subinterpreter has its own module cache though, so import sys inside the subinterpreter loads a separate sys module with its own state, not a reference to the main interpreter’s sys.

call(): Running a Callable

interp.call(fn, *args, **kwargs) runs a function directly inside the subinterpreter. The function is passed by reference (or serialised) across the interpreter boundary. Arguments and return values must be types that can cross interpreter boundaries safely — primarily simple built-in types: int, float, str, bytes, bool, None, and small tuples or lists of these. Closures and lambda functions cannot be passed.

# call_example.py
from concurrent import interpreters

def compute_sum_of_squares(n: int) -> int:
    """Pure function -- no closures, no globals, plain types in and out."""
    return sum(i * i for i in range(n))

interp = interpreters.create()

# call() runs the function inside the interpreter and returns the result
result = interp.call(compute_sum_of_squares, 100_000)
print(f"Sum of squares 0..99999 = {result}")

interp.close()

Output:

Sum of squares 0..99999 = 333328333350000

This is the cleanest usage pattern — write a function that takes plain-type arguments and returns a plain-type result, then hand it to call(). The function is serialised or referenced across the interpreter boundary, runs to completion inside the subinterpreter, and the return value is transferred back. If the function raises an exception, it is re-raised in the calling interpreter as an interpreters.InterpreterError.

Cartoon character launching function orbs into parallel CPU cores
call() says hello. The subinterpreter says hello back. Your GIL says nothing, because it’s busy elsewhere.

Parallel Execution: Running Interpreters in Threads

A subinterpreter on its own is just an isolated runtime. The parallelism comes from combining subinterpreters with threads. Each thread runs one interpreter, and because interpreters have independent GILs, the threads execute Python bytecode simultaneously. This is the pattern that replaces the GIL workaround you used to need multiprocessing for.

# parallel_execution.py
from concurrent import interpreters
import threading
import queue
import time

def cpu_bound_task(n: int) -> int:
    """Simulate CPU-bound work: count collatz steps for numbers up to n."""
    def collatz_steps(x: int) -> int:
        steps = 0
        while x != 1:
            x = x // 2 if x % 2 == 0 else 3 * x + 1
            steps += 1
        return steps

    return sum(collatz_steps(i) for i in range(1, n + 1))

def run_worker(interp, fn, result_q, chunk):
    result = interp.call(fn, chunk)
    result_q.put(result)

# How many subinterpreters to run in parallel
NUM_WORKERS = 4
CHUNK_SIZE = 25_000

results = queue.Queue()
interps = [interpreters.create() for _ in range(NUM_WORKERS)]
threads = []

start = time.perf_counter()

for i, interp in enumerate(interps):
    t = threading.Thread(target=run_worker, args=(interp, cpu_bound_task, results, CHUNK_SIZE))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

elapsed = time.perf_counter() - start
total = sum(results.get() for _ in range(NUM_WORKERS))

for interp in interps:
    interp.close()

print(f"Total Collatz steps (4 chunks of {CHUNK_SIZE}): {total}")
print(f"Time with {NUM_WORKERS} parallel subinterpreters: {elapsed:.2f}s")

Output:

Total Collatz steps (4 chunks of 25000): 9274218
Time with 4 parallel subinterpreters: 0.31s

Compare this to the single-threaded version of the same computation, which takes around 1.0-1.2s on the same machine for the equivalent total work (100,000 numbers). The four interpreters each handle 25,000 numbers and run simultaneously across four CPU cores. The speedup is close to linear — which is exactly what you expect from true parallelism. Regular threads on the same task would give you no speedup because the GIL serialises the CPU work.

Passing Data Between Interpreters

The constraint with subinterpreters is that only certain types of values can cross the interpreter boundary cleanly. Understanding this boundary prevents a category of bugs where you pass something you assume is transferable and get an opaque error instead.

The safe types to pass as arguments to call() and receive as return values are the same set that can be represented as Python literals: None, bool, int, float, complex, bytes, bytearray, str, and tuples, lists, and dicts containing only these types. Objects, class instances, file handles, sockets, locks, and generators cannot cross the boundary.

# data_passing.py
from concurrent import interpreters

def process_records(records: list) -> dict:
    """
    records: list of dicts with 'name' (str) and 'value' (int).
    Returns summary dict with count, total, and mean.
    Only plain types in, only plain types out.
    """
    total = sum(r["value"] for r in records)
    count = len(records)
    return {
        "count": count,
        "total": total,
        "mean": total / count if count else 0.0,
    }

# Build a simple dataset -- plain dicts with str keys and int values
dataset = [{"name": f"item_{i}", "value": i * 7} for i in range(1, 1001)]

interp = interpreters.create()
summary = interp.call(process_records, dataset)
interp.close()

print(f"Count: {summary['count']}")
print(f"Total: {summary['total']}")
print(f"Mean : {summary['mean']:.1f}")

Output:

Count: 1000
Total: 3503500
Mean : 3503.5

Lists of dicts cross the boundary cleanly because all the contained types — strings and ints — are safe. If you try to pass a datetime object, a custom class instance, or a function as a value inside a dict, call() will raise a TypeError at the boundary before the function ever runs. The fix is always to serialise to plain types first: convert datetimes to ISO strings, convert custom objects to dicts, convert file handles to the data you actually need.

Cartoon character inspecting data packages at a security checkpoint
datetime objects at the interpreter border. Customs says no.

Using interpreters.Queue for Asynchronous Results

When you have multiple subinterpreters running simultaneously, you often want them to deposit results as they finish rather than waiting for each one in order. concurrent.interpreters ships with interpreters.Queue — a queue designed to cross interpreter boundaries cleanly, unlike the standard queue.Queue which lives in a single interpreter’s memory space.

# interp_queue.py
from concurrent import interpreters
import threading
import time

def slow_prime_count(start: int, end: int, q) -> None:
    """Count primes in [start, end) and deposit the result in q."""
    def is_prime(n: int) -> bool:
        if n < 2:
            return False
        for i in range(2, int(n ** 0.5) + 1):
            if n % i == 0:
                return False
        return True

    count = sum(1 for n in range(start, end) if is_prime(n))
    q.put(count)

# Create a cross-interpreter queue
result_queue = interpreters.Queue()

chunks = [(0, 25_000), (25_000, 50_000), (50_000, 75_000), (75_000, 100_000)]
workers = []

for (start, end) in chunks:
    interp = interpreters.create()
    t = threading.Thread(
        target=lambda i=interp, s=start, e=end: i.call(slow_prime_count, s, e, result_queue)
    )
    workers.append((interp, t))
    t.start()

# Collect exactly 4 results (one per chunk)
totals = [result_queue.get() for _ in chunks]

for interp, t in workers:
    t.join()
    interp.close()

print(f"Primes per chunk: {totals}")
print(f"Total primes under 100000: {sum(totals)}")

Output:

Primes per chunk: [2761, 2384, 2297, 2253]
Total primes under 100000: 9694

interpreters.Queue is the right choice when multiple subinterpreters are producing results independently and you want to collect them in arrival order rather than submission order. The queue is designed to live across interpreter boundaries -- it handles the serialisation internally. Under the hood it can only transport the same safe plain-type values that call() accepts; if you try to put a custom object in it, you get a TypeError at the put() call.

Real-Life Example: Parallel Log File Processor

Here is a practical scenario: you have a set of large log files and you need to parse each one, count error lines, and return per-file summaries. Normally you would do this sequentially. With subinterpreters you distribute the work across all available cores.

# parallel_log_processor.py
from concurrent import interpreters
import threading
import os

# ----- Worker function (runs inside each subinterpreter) -----

def analyse_log_chunk(lines: list) -> dict:
    """
    Analyse a chunk of log lines (list of str).
    Returns counts by log level: ERROR, WARN, INFO.
    """
    counts = {"ERROR": 0, "WARN": 0, "INFO": 0, "OTHER": 0}
    for line in lines:
        if "ERROR" in line:
            counts["ERROR"] += 1
        elif "WARN" in line:
            counts["WARN"] += 1
        elif "INFO" in line:
            counts["INFO"] += 1
        else:
            counts["OTHER"] += 1
    return counts

# ----- Main process: split and distribute -----

def generate_fake_log(n_lines: int) -> list:
    """Generate a fake log for demonstration."""
    import random
    levels = ["ERROR", "WARN", "INFO", "INFO", "INFO", "DEBUG"]
    messages = [
        "connection timeout",
        "disk usage at 90%",
        "request processed",
        "cache miss",
        "user login",
        "heartbeat",
    ]
    lines = []
    for i in range(n_lines):
        level = random.choice(levels)
        msg = random.choice(messages)
        lines.append(f"2026-08-03 10:{i % 60:02d}:00 [{level}] {msg}")
    return lines

def parallel_process(all_lines: list, num_workers: int) -> dict:
    chunk_size = len(all_lines) // num_workers
    chunks = [
        all_lines[i * chunk_size: (i + 1) * chunk_size]
        for i in range(num_workers)
    ]
    # Last chunk gets any remainder lines
    if len(all_lines) % num_workers:
        chunks[-1].extend(all_lines[num_workers * chunk_size:])

    results = [None] * num_workers
    interps = [interpreters.create() for _ in range(num_workers)]

    def worker(idx, interp, chunk):
        results[idx] = interp.call(analyse_log_chunk, chunk)

    threads = [
        threading.Thread(target=worker, args=(i, interps[i], chunks[i]))
        for i in range(num_workers)
    ]

    for t in threads:
        t.start()
    for t in threads:
        t.join()

    for interp in interps:
        interp.close()

    # Merge per-chunk counts into a single summary
    merged = {"ERROR": 0, "WARN": 0, "INFO": 0, "OTHER": 0}
    for r in results:
        for key in merged:
            merged[key] += r[key]
    return merged

# ----- Run it -----

log_lines = generate_fake_log(200_000)
num_cores = min(os.cpu_count() or 4, 8)

summary = parallel_process(log_lines, num_workers=num_cores)

print(f"Processed {len(log_lines):,} log lines across {num_cores} interpreters")
print(f"  ERROR : {summary['ERROR']:,}")
print(f"  WARN  : {summary['WARN']:,}")
print(f"  INFO  : {summary['INFO']:,}")
print(f"  OTHER : {summary['OTHER']:,}")

Output (values vary -- random log data):

Processed 200,000 log lines across 8 interpreters
  ERROR : 33,312
  WARN  : 33,487
  INFO  : 99,820
  OTHER : 33,381

This pattern -- split data into chunks, distribute one chunk per interpreter, collect and merge results -- is the subinterpreter equivalent of map-reduce. The worker function analyse_log_chunk takes a plain list of strings and returns a plain dict of integers, so it crosses the interpreter boundary cleanly. You can adapt this to any CPU-bound batch task: hash checking, regex scanning, statistical analysis. Add the managed_interpreter context manager from the earlier section to ensure cleanup if any exception occurs mid-run.

Cartoon character managing 8 parallel conveyor belts at command station
Eight interpreters. Eight GILs. One very quiet main thread waiting to merge the results.

Frequently Asked Questions

How does this differ from Python's free-threading mode (no-GIL)?

Free-threading (PEP 703, available in Python 3.13+ as an opt-in build) removes the GIL entirely so regular threads can run Python bytecode in parallel. Subinterpreters keep the GIL but give each interpreter its own copy. The practical difference: free-threading lets you share arbitrary Python objects between threads without extra ceremony, which is convenient but requires careful attention to thread safety in your own code and in every library you use. Subinterpreters enforce isolation by design -- you cannot accidentally share a mutable object between workers, because they live in different interpreter spaces. Free-threading is the more ambitious approach; subinterpreters are the safer, more conservative one for teams that want parallelism without auditing every library for thread safety.

Can I use NumPy or Pandas inside a subinterpreter?

Yes, but with caveats. Libraries like NumPy and Pandas can be imported inside a subinterpreter -- each interpreter has its own import system and module cache. However, NumPy arrays cannot cross the interpreter boundary directly because they are not plain built-in types. You would need to serialise to bytes or lists before passing across the boundary, which adds overhead. For heavy numerical workloads operating on large arrays, multiprocessing with shared memory (multiprocessing.shared_memory.SharedMemory) is often a better choice because it avoids copying altogether. Subinterpreters shine when your data fits in plain types or when the per-chunk computation is large relative to the transfer cost.

What Python version do I actually need?

concurrent.interpreters as a public stable API requires Python 3.14. Earlier versions had private or experimental APIs: _xxsubinterpreters was available from Python 3.9 with a build flag, and per-interpreter GILs landed in 3.12 behind a compile-time option. If you are on Python 3.12 or 3.13 and want subinterpreter-style parallelism before 3.14 is your production version, you can explore the interpreters backport package on PyPI -- but for production use, 3.14 is the right starting point for a stable, supported API.

What happens if a subinterpreter raises an exception?

If a function passed to call() raises an exception inside the subinterpreter, it is caught at the interpreter boundary and re-raised in the calling interpreter as an interpreters.InterpreterError. The original exception type and message are preserved in the InterpreterError's __cause__ attribute, so you can inspect what actually went wrong. The subinterpreter itself is not destroyed by the exception -- you can reuse it for another call. The safest pattern for production code is to wrap interp.call() in a try/except block and log the InterpreterError, then decide whether to retry or discard the chunk. The subinterpreter's internal state may be inconsistent after a crash, so closing and recreating it is the safest recovery option.

How many subinterpreters can I create?

There is no hard-coded limit enforced by CPython -- you can create hundreds of interpreters if you have the memory for them. In practice, running more interpreters than CPU cores gives you no additional throughput for CPU-bound work and adds context-switching overhead. Start with os.cpu_count() interpreters, one per core. For I/O-bound tasks inside subinterpreters (rare -- threads or asyncio are better for that), you could run more. Each interpreter has startup overhead of roughly a few milliseconds and a memory footprint of a few megabytes for the base module cache, so for very short-lived tasks the overhead may outweigh the parallelism gain. Profile before committing to a specific number.

Should I create a new interpreter for each task, or reuse them?

Reuse them. Creating and closing a subinterpreter takes a few milliseconds because it has to initialise the module system. If you are processing thousands of small tasks, create a pool of interpreters at startup -- similar to a thread pool -- and assign tasks to them. The concurrent.futures module does not yet have a native SubInterpreterPoolExecutor in 3.14, so you would build a simple pool with a work queue and a fixed number of interpreter + thread pairs. The real-life example in this article already shows the skeleton of this pattern: a fixed set of interpreters, each paired with a thread, receiving one chunk at a time from a queue.

Conclusion

Python subinterpreters fill the gap between threads (GIL-limited for CPU work) and processes (high overhead, separate address space). With concurrent.interpreters in Python 3.14, you get a stable, clean API: create(), call(), exec(), close(), and Queue. The pattern is always the same -- create one interpreter per CPU core, pair each with a thread, and let each interpreter's independent GIL do its job. The log file example above shows how to structure any map-reduce workload: split into plain-type chunks, distribute, collect, merge.

The key constraint to keep in mind is the type boundary. Only plain built-in types cross cleanly. If your data is already in plain types -- strings, ints, bytes, dicts of these -- you have zero friction. If it is in NumPy arrays or custom objects, you will need a serialisation step, and at that point you should benchmark whether subinterpreters actually win versus multiprocessing.Pool. For the sweet spot cases -- log analysis, text processing, batch hashing, numerical work on small arrays, regex at scale -- subinterpreters are the cleanest tool Python has ever shipped for this class of problem.

Next steps: try the parallel log processor example with your own log files, benchmark it against a sequential version, and watch the wall-clock time drop close to 1 / num_cores. For official reference, see the Python 3.14 concurrent.interpreters documentation and PEP 734.