Multiprocessing
In this cookbook recipe, you'll learn how to use Python's built-in
multiprocessing module —
including concurrent.futures.ProcessPoolExecutor —
from a Flet app, for true CPU parallelism across processes.
For I/O-bound work, or work that just needs to stay off the UI thread, prefer
async or threads — they are lighter and work on every platform.
Reach for multiprocessing when you need multiple CPU cores doing Python work
at the same time (number crunching, batch processing, ML inference, etc.), or
when you need process isolation for work that may fail or need to be stopped.
multiprocessing works in Flet desktop apps during development (flet run) and
in packaged desktop apps built with flet build {macos,windows,linux}
or flet debug {macos,windows,linux} when using Flet v0.86.0 or newer.
It is not supported on iOS and Android (mobile operating systems don't
allow apps to spawn arbitrary child processes) or in the browser. On those
platforms, prefer threads or asyncio instead.
How does it work?
In a desktop app packaged with flet build, there is no separate python executable —
the interpreter is embedded inside your app's binary. When multiprocessing spawns a
worker, it re-executes that binary with a CPython helper command line; the binary
recognizes that shape and services it as a plain, windowless Python interpreter.
This also covers multiprocessing's helper processes (the resource tracker and the forkserver).
Guidelines
These are standard
Python multiprocessing guidelines
— but in a packaged Flet app they are mandatory, not just good style.
Always guard your entry point
Start your app only under the if __name__ == "__main__": guard. For example:
import flet as ft
def main(page: ft.Page):
...
if __name__ == "__main__":
ft.run(main)
With the spawn and forkserver start methods, worker/helper processes need
to safely import your main module. spawn is the default on macOS and Windows;
forkserver is the default on Linux starting with Python 3.14. Without the
guard, a child process can try to start your whole app again.
Use importable, picklable worker functions
Worker targets, arguments, and return values must be picklable so Python can send them between processes. In practice:
- define worker functions at module top level, not inside
main()or inside a button handler - pass plain data such as numbers, strings, lists, dicts, or dataclasses
- do not pass Flet controls,
page, database connections, open files, lambdas, or nested functions
Good:
def sort_chunk(chunk):
return sorted(chunk)
Avoid:
def main(page: ft.Page):
def sort_chunk(chunk):
return sorted(chunk)
The nested version is not reliably picklable because worker processes need to import the function by name from a module.
Don't touch the GUI from workers
Worker processes run in a separate interpreter with no connection to your app's
page. Pass data back through multiprocessing.Queue,
Pipe, or
pool futures,
and update the UI from the main process.
Others
sys.executablein a packaged app points at your app's binary, not apythonexecutable. That is intentional — don't override it withmultiprocessing.set_executable().- You usually do not need
multiprocessing.freeze_support()in Flet apps. Calling it inside theif __name__ == "__main__":block is harmless, but Flet does not rely on PyInstaller-style frozen-executable bootstrapping. - Worker
print()output is not connected to your app's console log; use aQueueor file-based logging if you need worker diagnostics. - On Linux, avoid forcing the
forkstart method: your app's process runs the Flutter engine with many active threads, and forking it is unsafe. Prefer the platform default (usuallyforkserverorspawn), or request one explicitly withmultiprocessing.set_start_method()ormultiprocessing.get_context(). - Starting a worker costs more in a packaged app than in plain Python — each spawned child loads your app binary's libraries before Python takes over. Create pools and long-lived workers once and reuse them, rather than spawning per button click (see Keep a persistent worker example).
Examples
Parallel sort with live progress
Sort chunks of data across all CPU cores and stream progress to the page:
import random
from concurrent.futures import ProcessPoolExecutor, as_completed
import flet as ft
def sort_chunk(chunk: list[float]) -> list[float]:
"""Sort one chunk of data."""
return sorted(chunk)
def main(page: ft.Page):
def run_sort():
"""Orchestrate the pool from a background thread, updating the UI
from the main process as results come in."""
chunks = [[random.random() for _ in range(250_000)] for _ in range(8)]
completed = 0
# Without max_workers, the pool sizes itself to the machine's CPUs.
with ProcessPoolExecutor() as pool:
futures = [pool.submit(sort_chunk, c) for c in chunks]
# as_completed() yields each future as soon as its worker is done.
for _ in as_completed(futures):
completed += 1
progress.value = completed / len(futures)
status.value = f"Sorted {completed}/{len(futures)} chunks"
page.update()
status.value = "Done!"
page.update()
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Button(
"Start sorting", on_click=lambda: page.run_thread(run_sort)
),
status := ft.Text("Idle"),
progress := ft.ProgressBar(value=0, width=300),
]
)
)
)
if __name__ == "__main__":
ft.run(main)
Note how the long-running orchestration is moved off the UI event handler with
page.run_thread, while the CPU-heavy work runs in the
process pool. The worker function may live in your main module (as above) or in
a separate importable module — both work.
Stream progress from a worker
To show fine-grained progress from inside a single long-running job, pass a
multiprocessing.Queue
to the worker and drain it on a background thread. The worker put()s progress
values and a None sentinel when it is done:
import multiprocessing
import flet as ft
def crunch(progress_queue: multiprocessing.Queue, steps: int) -> None:
"""Do `steps` slices of CPU-heavy work, reporting progress after each one.
Runs in a worker process, 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(steps):
sum(range(50_000_000)) # one slice of real work
progress_queue.put((i + 1) / steps)
progress_queue.put(None) # sentinel: no more updates
def main(page: ft.Page):
def start():
button.disabled = True
status.value = "Starting worker…"
page.update()
queue = multiprocessing.Queue()
worker = multiprocessing.Process(target=crunch, args=(queue, 20), daemon=True)
worker.start()
page.run_thread(drain, queue, worker)
def drain(queue: multiprocessing.Queue, worker: multiprocessing.Process):
"""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"Crunching… {value:.0%}"
page.update()
worker.join()
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)
Keep a persistent worker
Starting a process is not free — especially in a packaged app, where each child
loads your app binary's libraries first. For repeated jobs, start one worker
that stays alive and serves requests over a
Pipe,
paying the startup cost once. Expensive setup (loading a model, opening a
dataset) can then also happen once, in the worker:
import multiprocessing
import time
import flet as ft
def calc_worker(conn) -> None:
"""Serve calculation jobs over the pipe until told to stop.
Runs in a worker process that stays alive across jobs, so the process
startup cost is paid once. Expensive setup (loading models, opening
datasets, warming caches) could be done once here, before the loop,
and reused by every job. Receiving `None` is the shutdown signal.
"""
while (job := conn.recv()) is not None:
started = time.perf_counter()
result = sum(i * i for i in range(job))
conn.send((result, time.perf_counter() - started))
conn.close()
def main(page: ft.Page):
# One end of the pipe goes to the worker, the other stays with the UI.
parent_conn, child_conn = multiprocessing.Pipe()
worker = multiprocessing.Process(
target=calc_worker, args=(child_conn,), daemon=True
)
worker.start()
def submit():
# One job in flight at a time: the button stays disabled until the
# worker replies (a bare Pipe is not multiplexed).
button.disabled = True
status.value = "Working…"
page.update()
page.run_thread(request)
def request():
"""Send a job to the worker and wait for its reply.
Should be run on a background thread: conn.recv() blocks until the worker
answers, so it must stay off the UI event loop.
"""
parent_conn.send(100_000_000)
result, duration = parent_conn.recv()
status.value = (
f"sum of squares = {result}\n{duration:.2f}s in process {worker.pid}"
)
button.disabled = False
page.update()
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
button := ft.Button("Compute in worker", on_click=submit),
status := ft.Text(f"Worker ready (pid {worker.pid})"),
]
)
)
)
if __name__ == "__main__":
ft.run(main)
The worker is started with daemon=True, so it is terminated automatically
when your app exits.
Cancel a runaway task
Threads cannot be forcefully stopped from the outside — a worker Process can,
at any time, with terminate().
This makes processes the right tool for jobs you may need to abort, such as
long calls into external libraries:
import multiprocessing
import time
import flet as ft
def long_job(seconds: int) -> None:
"""Burn CPU for roughly `seconds` seconds.
Stands in for work you cannot interrupt from Python itself, e.g. a tight
loop inside a C extension library. Runs in a worker process, which is why
it can be cancelled at any point — threads can't be stopped like that.
"""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
sum(range(1_000_000))
def main(page: ft.Page):
process = None
def watch(p: multiprocessing.Process):
"""Wait (on a background thread) for the worker to end, then report
whether it finished on its own or was cancelled."""
p.join()
if p.exitcode == 0:
status.value = "Process finished normally."
else:
# terminate() ends the child with a negative exit code (-SIGTERM).
status.value = f"Process was terminated (exit code {p.exitcode})."
start.disabled = False
cancel.disabled = True
page.update()
def start_job():
nonlocal process
process = multiprocessing.Process(target=long_job, args=(30,), daemon=True)
process.start()
status.value = f"Running in process {process.pid}…"
start.disabled = True
cancel.disabled = False
page.update()
page.run_thread(watch, process)
def cancel_job():
if process is not None and process.is_alive():
process.terminate() # threads can't do this
page.add(
ft.SafeArea(
content=ft.Column(
controls=[
ft.Row(
controls=[
start := ft.Button("Start 30s job", on_click=start_job),
cancel := ft.Button(
"Cancel", on_click=cancel_job, disabled=True
),
]
),
status := ft.Text(),
]
)
)
)
if __name__ == "__main__":
ft.run(main)
A background thread join()s the worker and reports how it ended — normally
(exit code 0) or via cancellation (a negative exit code).
Python's multiprocessing guidelines recommend avoiding process termination or doing it only for processes which never use any shared resources.