Skip to main content

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.

Platform and Flet version support

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.executable in a packaged app points at your app's binary, not a python executable. That is intentional — don't override it with multiprocessing.set_executable().
  • You usually do not need multiprocessing.freeze_support() in Flet apps. Calling it inside the if __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 a Queue or file-based logging if you need worker diagnostics.
  • On Linux, avoid forcing the fork start method: your app's process runs the Flutter engine with many active threads, and forking it is unsafe. Prefer the platform default (usually forkserver or spawn), or request one explicitly with multiprocessing.set_start_method() or multiprocessing.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).

info

Python's multiprocessing guidelines recommend avoiding process termination or doing it only for processes which never use any shared resources.