Skip to main content

Subinterpreters

In this cookbook recipe, you'll learn how to use Python 3.14's subinterpreters (concurrent.interpreters and concurrent.futures.InterpreterPoolExecutor) for true multi-core CPU parallelism in a Flet app.

A subinterpreter is a separate Python interpreter running inside the same process. Since Python 3.12 each one has its own GIL, so several subinterpreters can run pure-Python code on several CPU cores at once — without starting separate processes.

When to use which

runs ontrue CPU parallelismnotes
threadsone interpreter, one GIL❌ (pure Python)best for I/O, or C libraries that release the GIL
subinterpretersone process, N interpretersin-process, works on mobile; restricted data sharing; can't force-cancel
multiprocessingN processesfull isolation, can hard-cancel a worker; heavier, desktop-only in Flet

Reach for subinterpreters when you need multiple cores for Python work and want to stay in one process — especially on mobile, where multiprocessing cannot spawn child processes at all.

Platform and version support

Subinterpreters require Python 3.14 or later. When packaging your app using flet build, ensure that the bundled Python version meets this requirement. In development (e.g., when using flet run), the Python interpreter in your virtual environment must also meet this requirement.

They work in Flet apps on macOS, Windows, Linux, iOS, and Android.

On the web it depends on where your Python actually runs:

Rules

Define workers at module top level

To run a worker in another interpreter, CPython copies it there — its code plus the module-level functions and constants it references. Define workers at the top level of a module (your main.py, as the examples below do, or a separate file); both behave identically on macOS, Windows, Linux, iOS, and Android.

A worker function nested inside main() or a button handler only works if it is stateless — no captured variables and no module globals — so the moment it references a helper or a constant it fails with NotShareableError: only stateless functions are shareable. A top-level function has no such limit: it can freely call other module-level helpers. (Also don't call a helper from inside a generator expression — see Caveats.)

Pass only picklable / shareable data

Arguments and return values are pickled to cross the interpreter boundary, so they must be picklable. The low-level Queue additionally accepts shareable objects directly — numbers, str, bytes, None, tuples of those, and the queue itself. Don't pass Flet controls, page, open files, or database connections.

Don't touch the GUI from a subinterpreter

Workers run in an isolated interpreter with no access to your page. Return data (or stream it through a Queue) and update the UI from the main interpreter.

Examples

Parallel map across cores

InterpreterPoolExecutor is a drop-in alternative to ProcessPoolExecutor: it runs each task in a subinterpreter and, because each has its own GIL, uses several cores at once — all in one process.

import time
from concurrent.futures import InterpreterPoolExecutor, as_completed

import flet as ft


def _is_prime(n: int) -> bool:
"""Returns True if `n` is prime."""
if n < 2:
return False
for d in range(2, int(n**0.5) + 1):
if n % d == 0:
return False
return True


def count_primes(limit: int) -> int:
"""Count the primes below `limit` (CPU-bound, pure Python)."""
count = 0
for n in range(2, limit):
if _is_prime(n):
count += 1
return count


def main(page: ft.Page):
def start():
button.disabled = True # block a second run while this one is in flight
page.update()
page.run_thread(run)

def run():
"""Time the same work sequentially and across a pool, then report the
speedup. Runs on a background thread so the UI stays responsive."""
limits = [200_000 + i * 30_000 for i in range(8)]

# Baseline: run every chunk in this one interpreter (one core).
status.value = "Sequential…"
progress.value = 0
page.update()
started = time.perf_counter()
for done, limit in enumerate(limits, 1):
count_primes(limit)
progress.value = done / len(limits)
page.update()
seq_time = time.perf_counter() - started

# Parallel: one subinterpreter per chunk, each with its own GIL.
status.value = "Parallel…"
progress.value = 0
page.update()
primes = completed = 0
started = time.perf_counter()
with InterpreterPoolExecutor() as pool: # sizes itself to the CPU count
futures = [pool.submit(count_primes, n) for n in limits]
for future in as_completed(futures):
primes += future.result()
completed += 1
progress.value = completed / len(futures)
page.update()
par_time = time.perf_counter() - started

status.value = (
f"{primes} primes · sequential {seq_time:.1f}s · "
f"parallel {par_time:.1f}s · {seq_time / par_time:.1f}× faster"
)
button.disabled = False
page.update()

page.add(
ft.SafeArea(
content=ft.Column(
controls=[
button := ft.Button(
"Count primes: sequential vs parallel",
on_click=start,
),
progress := ft.ProgressBar(value=0, width=300),
status := ft.Text(),
]
)
)
)


if __name__ == "__main__":
ft.run(main)

The example times the same work run sequentially and then across the pool, and reports the speedup. Orchestration runs off the UI thread with page.run_thread, and parallel results are collected as each task lands via as_completed. The pool only wins when each task does enough work to outweigh the cost of starting a subinterpreter — the speedup is largest on a multi-core desktop, and smaller on mobile, where startup costs more and there are fewer cores.

Stream progress from a subinterpreter

To show fine-grained progress from a single long job, share a Queue with the subinterpreter. The worker puts progress values; a background thread drains them into the UI:

import threading
from concurrent import interpreters

import flet as ft


def _is_prime(n: int) -> bool:
"""Returns True if `n` is prime."""
if n < 2:
return False
for d in range(2, int(n**0.5) + 1):
if n % d == 0:
return False
return True


def _count_in_range(lo: int, hi: int) -> int:
"""Returns the number of primes in the half-open range [lo, hi)."""
count = 0
for n in range(lo, hi):
if _is_prime(n):
count += 1
return count


def stream_primes(progress_queue, chunks: int, per_chunk: int) -> None:
"""Count primes in `chunks` slices, reporting progress after each one.

Runs in a subinterpreter, which has no access to the page — the queue is
the only channel back to the UI. Values are fractions 0..1; a final `None`
tells the consumer there is nothing more to read.
"""
for i in range(chunks):
lo = i * per_chunk + 2
_count_in_range(lo, lo + per_chunk)
progress_queue.put((i + 1) / chunks)
progress_queue.put(None) # sentinel: no more updates


def main(page: ft.Page):
def start():
button.disabled = True
status.value = "Working…"
page.update()

# A queue shared between this interpreter and the subinterpreter. Only
# "shareable" objects cross it (numbers, str, bytes, None, tuples of
# those, and the queue itself).
queue = interpreters.create_queue()

interp = interpreters.create()
drained = threading.Event()

# The worker runs the job in the subinterpreter and blocks that thread,
# so it goes on its own background thread…
page.run_thread(work, interp, queue, drained)
# …while a second thread drains progress and drives the UI.
page.run_thread(drain, queue, drained)

def work(interp, queue, drained):
"""Run the job in the subinterpreter, then close it once the UI is done.

On its own thread because interp.call() blocks until the job finishes.
The interpreter is closed only after `drained` is set — a subinterpreter
Queue's pending items go invalid the moment its interpreter closes.
"""
interp.call(stream_primes, queue, 20, 100_000)
drained.wait()
interp.close()

def drain(queue, drained):
"""Forward the worker's progress reports to the UI.

Runs on a background thread: queue.get() blocks until the worker
reports again, so it must stay off the UI event loop.
"""
while (value := queue.get()) is not None:
progress.value = value
status.value = f"Counting… {value:.0%}"
page.update()
drained.set()
status.value = "Done!"
button.disabled = False
page.update()

page.add(
ft.SafeArea(
content=ft.Column(
controls=[
button := ft.Button("Start", on_click=start),
progress := ft.ProgressBar(value=0, width=300),
status := ft.Text(),
]
)
)
)


if __name__ == "__main__":
ft.run(main)

Two details worth noting: the worker runs on its own thread because interp.call() blocks until the job finishes, and the interpreter is closed only after the UI has drained every item — a subinterpreter Queue's pending items become invalid the moment its interpreter closes.

Keep a persistent, stateful interpreter

Creating an interpreter with interpreters.create() isn't free, so don't create one per task. Create it once and reuse it: the state a worker builds (here, the prime table cached in a module global) persists in that interpreter between calls, so expensive setup happens only on the first call.

import atexit
import time
from concurrent import interpreters

import flet as ft

_UPPER = 2_000_000
_primes: list[int] | None = None


def _is_prime(n: int) -> bool:
"""Returns True if `n` is prime."""
if n < 2:
return False
for d in range(2, int(n**0.5) + 1):
if n % d == 0:
return False
return True


def nth_prime(n: int) -> dict:
"""Returns the n-th prime (1-indexed), building a prime table on first call.

Runs in a long-lived subinterpreter that keeps its state across calls, so
the table is built once (cached in a module global) and reused after that.
The build stands in for genuinely expensive setup — loading a model, opening
a dataset, warming a cache.
"""
global _primes
built = _primes is None
if built:
table = []
for x in range(2, _UPPER):
if _is_prime(x):
table.append(x)
_primes = table
return {
"prime": _primes[n - 1],
"built_this_call": built,
"table_size": len(_primes),
}


def main(page: ft.Page):
# One long-lived subinterpreter, created once and reused for every query,
# so its cached state survives.
interp = interpreters.create()
atexit.register(interp.close) # close it at exit

def query():
button.disabled = True
page.update()
page.run_thread(run)

def run():
"""Call the interpreter on a background thread and show the result."""
started = time.perf_counter()
result = interp.call(nth_prime, 100_000)
elapsed = time.perf_counter() - started
how = "built the table" if result["built_this_call"] else "reused cache"
status.value = f"100,000th prime = {result['prime']}\n{how} in {elapsed:.2f}s"
button.disabled = False
page.update()

page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Text(
"Click twice: the first query builds a prime table, the "
"second reuses it from the live interpreter."
),
button := ft.Button("Find the 100,000th prime", on_click=query),
status := ft.Text(),
]
)
)
)


if __name__ == "__main__":
ft.run(main)

Click twice: the first query builds the prime table (slow), the second reuses it from the live interpreter (instant). In a real app that table stands in for a loaded model, an opened dataset, or a warmed cache.

Because this interpreter lives for the whole session, the example registers atexit to close() it at shutdown — otherwise Python warns that a subinterpreter was left open.

Caveats

  • You can't force-cancel a subinterpreter: It runs on a thread, so — unlike a multiprocessing.Process — there's no terminate(). If you need to abort a runaway task, use multiprocessing instead.
  • Not every C extension supports subinterpreters: An extension must opt in (multi-phase initialization with per-interpreter GIL support); some third-party native libraries don't yet and raise ImportError when imported in a subinterpreter. Pure-Python code and the standard library work.
  • Reuse interpreters and pools: Interpreter startup isn't free (each re-imports its modules); create a pool or a persistent interpreter once rather than per task.
  • Call module-level helpers from a loop or list comprehension, not a generator expression: The names a generator expression looks up aren't carried into the subinterpreter, so sum(f(x) for x in ...) referencing a module-level f raises NameError — in a module just as much as in main.py. A plain for loop or a list comprehension (which Python inlines) works instead, as these examples do.