DataFusion supports splitting work across processes by shipping serialized expressions to workers: the driver builds an {py:class}~datafusion.Expr, each worker evaluates it against its own slice of data. This pattern suits embarrassingly-parallel workloads where the driver decides partitioning up front.
Query-level distribution — where the runtime partitions a single logical or physical plan across worker nodes — is in progress upstream via datafusion-distributed and Apache Ballista. Both have short sections at the end of this page; integration details will land as those projects become usable from datafusion-python.
DataFusion expressions support distribution directly: pass one to a worker process and Python‘s standard pickle machinery serializes it transparently — the same machinery {py:meth}multiprocessing.pool.Pool.map, Ray’s @ray.remote, and similar libraries already use to ship function arguments. Python UDFs — scalar, aggregate, and window — travel inside the serialized expression; the receiver does not need to pre-register them.
Define a worker function that takes the expression plus a batch and returns the evaluated result:
import pyarrow as pa from datafusion import SessionContext def evaluate(expr, batch): # `expr` arrived here via the pool's automatic pickling — # no manual serialization needed in user code. ctx = SessionContext() df = ctx.from_pydict({"a": batch}) return df.with_column("result", expr).select("result").to_pydict()["result"]
Then build the expression in the driver and fan it out:
import multiprocessing as mp from datafusion import col, udf double = udf( lambda arr: pa.array([(v.as_py() or 0) * 2 for v in arr]), [pa.int64()], pa.int64(), volatility="immutable", name="double", ) expr = double(col("a")) mp_ctx = mp.get_context("forkserver") with mp_ctx.Pool(processes=4) as pool: results = pool.starmap( evaluate, [(expr, [1, 2, 3]), (expr, [10, 20, 30])], ) print(results) # [[2, 4, 6], [20, 40, 60]]
:::{note} When saved to a .py file and executed with the spawn or forkserver start method, wrap the driver block in if __name__ == "__main__": so worker processes can re-import the module without re-running it. This is a standard Python {py:mod}multiprocessing requirement, not DataFusion-specific — see Safe importing of main module in the Python docs. :::
Built-in functions (abs, length, arithmetic, comparisons, etc.) — fully portable. Worker needs nothing pre-registered.
Python UDFs — travel inline (subject to the two portability requirements below). The callable, its signature, and any state captured in closures travel inside the serialized expression and are reconstructed on the worker automatically. Applies equally to:
datafusion.udf)datafusion.udaf)datafusion.udwf)UDFs imported via the FFI capsule protocol — travel by name only. The worker must already have a matching registration on its {py:class}SessionContext. Without that registration, evaluation raises an error.
(distributed_udf_portability)=
Inline Python UDFs ride on cloudpickle, which imposes two requirements on the worker environment:
(major, minor); mismatches raise a clear error naming both versions. Align the Python version on driver and workers.import are captured by reference — module path only. A UDF doing from mylib import transform requires mylib installed on the worker. Same applies to bound methods of imported classes. Self-contained UDFs (no imports beyond what the worker already has, e.g. pyarrow) avoid this entirely.When an expression references an FFI capsule UDF (or any UDF the worker must resolve from its registered functions), set up the worker's {py:class}SessionContext once per process and install it as the worker context:
from datafusion import SessionContext from datafusion.ipc import set_worker_ctx def init_worker(): ctx = SessionContext() ctx.register_udaf(my_ffi_aggregate) set_worker_ctx(ctx) with mp.get_context("forkserver").Pool( processes=4, initializer=init_worker ) as pool: ...
Inside a worker, expressions arriving from the driver resolve their by-name references against the installed worker context. If no worker context is installed, the global {py:class}SessionContext is used — fine for expressions that only reference built-ins and Python UDFs, but FFI-capsule-backed registrations must be installed on the global context to resolve.
Python 3.14 changed the Linux default start method for {py:mod}multiprocessing from fork to forkserver (macOS has defaulted to spawn since Python 3.8; Windows has always used spawn). With fork, any state set in the parent was visible in workers via copy-on-write; with forkserver and spawn it is not. The {py:func}~datafusion.ipc.set_worker_ctx pattern works on every start method — prefer it over relying on inherited state.
~datafusion.ipc.set_worker_ctx and referring to it by name cuts the per-trip overhead.Portability requirements for inline Python UDFs <distributed_udf_portability> for the Python-version and imported-module rules.For a stricter wire format, call {py:meth}SessionContext.with_python_udf_inlining(enabled=False) <datafusion.SessionContext.with_python_udf_inlining> on the session producing or consuming the bytes. With inlining disabled, Python UDFs travel by name only — the same way FFI-capsule UDFs do — and the receiver must have a matching registration.
Two use cases:
Expr.from_bytes never calls cloudpickle.loads on the incoming bytes — an inline payload from a misbehaving sender raises a clear error instead of executing arbitrary Python code.Mismatched configurations raise a descriptive error: an inline blob fed to a strict receiver fails fast rather than silently dropping into cloudpickle.loads.
To make the toggle apply through {py:func}pickle.dumps (which calls {py:meth}Expr.to_bytes with no context), install the strict session as the driver's sender context:
from datafusion import SessionContext from datafusion.ipc import set_sender_ctx set_sender_ctx(SessionContext().with_python_udf_inlining(enabled=False)) # Every subsequent pickle.dumps(expr) on this thread encodes # without inlining the Python callable.
Pair with a matching strict worker context ({py:func}~datafusion.ipc.set_worker_ctx) so the pickle.loads side also refuses inline payloads. Explicit {py:meth}Expr.to_bytes(ctx) <Expr.to_bytes> and {py:meth}Expr.from_bytes(blob, ctx=ctx) <Expr.from_bytes> calls honor the supplied ctx directly and ignore the sender / worker contexts.
The toggle only narrows the {py:meth}Expr.from_bytes surface; {py:func}pickle.loads on untrusted bytes remains unsafe regardless of this setting. See the {ref}Security <distributed_expr_security> section below for the full threat model.
(distributed_expr_security)=
:::{warning} Reconstructing an expression containing a Python UDF executes arbitrary Python code on the receiver — pickle is doing the work under the hood and pickle is unsafe on untrusted input (see the pickle module security warning in the Python standard library docs). Only accept expressions from trusted sources. For untrusted-source workflows, disable Python UDF inlining (see above), restrict senders to built-in functions and pre-registered Rust-side UDFs, and avoid {py:func}pickle.loads on externally supplied bytes entirely. :::
There is only one type — {py:class}SessionContext. It can occupy up to four slots in a running program:
| Slot | Lifetime | Purpose | Set how |
|---|---|---|---|
| User-held | Local variable / attribute | Build and run queries | ctx = SessionContext(...) |
| Global | Process singleton (lazy-init) | Backs module-level {py:func}~datafusion.io.read_parquet, {py:func}~datafusion.io.read_csv, {py:func}~datafusion.io.read_json, {py:func}~datafusion.io.read_avro; final fallback for {py:meth}Expr.from_bytes | Implicit; access via {py:meth}SessionContext.global_ctx |
| Sender | Thread-local on the driver | Codec settings for outbound {py:func}pickle.dumps / {py:meth}Expr.to_bytes without ctx | {py:func}~datafusion.ipc.set_sender_ctx |
| Worker | Thread-local on the worker | Function registry for inbound {py:func}pickle.loads / {py:meth}Expr.from_bytes without ctx | {py:func}~datafusion.ipc.set_worker_ctx |
The same {py:class}SessionContext object may occupy more than one slot simultaneously — installing it into a slot is a reference, not a copy. A non-distributed program only ever uses the user-held slot; the global slot is invisible unless you call top-level read_* helpers.
Resolution order on the worker side is explicit argument → worker context → global context. Explicit ctx= on {py:meth}Expr.from_bytes always wins; the sender slot is ignored on decode and the worker slot is ignored on encode.
Sharp edges:
None until they install their own.fork start method, the parent's threading.local() values are copied into the child by copy-on-write — a forked worker initially observes whatever sender / worker slot the parent had set, until the worker writes its own value (or calls the matching clear_*_ctx). spawn and forkserver workers start with empty thread-local slots. Treat the slot as uninitialized on worker entry and install (or clear) it explicitly in the worker initializer; do not rely on inherited state.fork workers (copy-on-write memory inherit) but not across spawn / forkserver workers (fresh process — register or install a worker context on start-up).🚧 Work in progress upstream — not yet usable from datafusion-python.
datafusion-distributed splits a single physical plan into stages and runs each stage on a different worker node. The driver writes a SQL or DataFrame query once; the runtime handles partitioning, shuffles, and reassembly.
A datafusion-python integration is in development. This section will document the integration once it lands. In the meantime, the expression-level approach above covers most use cases that do not require automatic plan partitioning.
🚧 Work in progress upstream — not yet usable from datafusion-python.
Apache Ballista provides distributed query execution on top of DataFusion with a scheduler / executor model better suited to long-lived cluster deployments. A datafusion-python integration is on the roadmap; this section will fill in once the integration is usable.
datafusion.ipc — worker context API.examples/multiprocessing_pickle_expr.py — runnable multiprocessing.Pool example that ships a different parametric expression to each worker and collects results back.examples/ray_pickle_expr.py — runnable Ray actor example.