PySpark UDFs, Arrow & the Python boundary
See where your Python function actually runs, what crosses into it, and why the fastest UDF is usually the one you did not need to write.
A Python UDF runs in separate Python worker processes, so data must be serialized out of the JVM and back, and Catalyst treats the function as a black box. Arrow changes how data crosses that boundary; only a pandas UDF also changes how your function processes it.
Where Python runs
Your PySpark program runs in a Python process on the driver, but DataFrame calls — filter, groupBy, even F.upper — only build a plan that the JVM executes (see RDDs, DataFrames & Spark SQL). When built-in expressions run on executors, no Python is involved.
A Python UDF is different: its function must run in Python beside the data. Executors hand that work to Python worker processes, reused across tasks by default (spark.python.worker.reuse=true). In a local run, a UDF returning os.getpid() reported worker PIDs 31531 and 31532, while the driver's Python PID was 31455 — the function never ran in the driver's interpreter.
For an ordinary Python UDF, the BatchEvalPython operator projects the UDF's input columns, groups rows into batches, pickles them, and writes them over a socket to the worker. The worker unpickles each batch, calls your function once per row, pickles the results, and sends them back. The JVM unpickles each result and pairs it with the original row, which it held in a queue meanwhile. Every row pays for conversion into Python objects and back, plus a Python function call.
Check the sources: EvalPythonExec · BatchEvalPythonExec · Python worker settings
Why the optimizer stops at a UDF
Catalyst cannot read Python code, so a UDF is an opaque expression with a declared return type. ExtractPythonUDFs moves Python UDFs out of projections and filters into dedicated operators, and the plan makes the boundary visible. Upper-casing a name column four ways, followed by filter(id > 1):
| Implementation | Operator in the plan | Whole-stage codegen |
|---|---|---|
F.upper("name") | Project with upper(name#1) | One stage: *(1) project, filter, scan |
@udf | BatchEvalPython [upper_py(name#1)] | *(1) below, *(2) above |
@udf(useArrow=True) | ArrowEvalPython [...], 101 | *(1) below, *(2) above |
@pandas_udf (Series to Series) | ArrowEvalPython [...], 200 | *(1) below, *(2) above |
The trailing number is the Python evaluation type: 101 is an Arrow-batched Python UDF and 200 a scalar pandas UDF. Two consequences follow.
- Codegen splits around the Python operator; operators below and above still generate code, in separate stages.
- Predicates on the UDF's result cannot be pushed. On a Parquet dataset,
filter(amount > 400)pushedGreaterThan(amount,400)into the scan; the same test written as a Python UDF leftPushedFilters: []and aFilteraboveBatchEvalPython, so every row was read first. Both returned 594 rows. Predicates that do not touch the UDF still move below it:id > 1ran before the UDF in all four plans.
Spark treats a UDF as deterministic unless you call asNondeterministic(), and the API notes warn that it may be invoked more or fewer times than it appears in the query. Selecting a UDF result and then filtering on that column pushed the filter below the projection by copying the expression, so the plan evaluated the UDF in two BatchEvalPython operators; marked non-deterministic, it was evaluated once. See Catalyst, code generation & AQE for how to read these plans.
Check the sources: ExtractPythonUDFs · Python eval types · udf notes
Arrow: batches across the boundary, pandas inside it
Apache Arrow is a columnar in-memory format. Spark uses it to move column batches between the JVM and Python instead of pickled rows. What your function receives depends on the API:
- Arrow-optimized Python UDF —
@udf(..., useArrow=True), or for every eligible UDFspark.sql.execution.pythonUDF.arrow.enabled(default false; the setting dates from Spark 3.4.0). Transfer uses Arrow, but the function is still called row by row: the 3.5.7 worker loops over each batch and calls it per row, and the guide calls these UDFs "executed row-by-row." Arrow's return-type coercion can differ from the pickled path, which the source gives as the reason the option is off by default. - pandas UDF —
@pandas_udfwith Python type hints. Series to Series (output length must equal input); Iterator of Series to Iterator of Series, useful when execution needs expensive state initialized once; Iterator of multiple Series; and Series to scalar, an aggregate forgroupBy().agg()or unbounded windows that loads each group into memory with no partial aggregation (planned asAggregateInPandasafter anExchangeandSort). The function receives whole batches, so vectorized pandas or NumPy code avoids a Python call per row. Type hints are preferred; the guide saysPandasUDFTypewill be deprecated. - pandas function APIs take and return pandas DataFrames.
mapInPandasmaps an iterator of DataFrames per partition and may return any number of rows; it planned asMapInPandasover the scan, with no shuffle.groupBy().applyInPandashands each group to your function as one DataFrame and requires a full shuffle:Exchange,Sort,FlatMapGroupsInPandas.cogroup().applyInPandasdoes the same for two grouped DataFrames.
Batch size. spark.sql.execution.arrow.maxRecordsPerBatch (default 10,000) caps rows per Arrow batch. A pandas UDF over one 25,000-row partition saw batches of 10,000, 10,000, and 5,000 rows; at 1,000, every batch had 1,000 rows. The cap counts rows, not bytes, so wide rows may need a lower value. It does not apply to applyInPandas or cogroup: each whole group is one batch, and a skewed group can exhaust the worker.
Check the sources: Arrow and pandas guide · worker.py · pandas_udf · mapInPandas · applyInPandas
Memory for Python workers
Python workers are native processes outside the executor's JVM heap, so spark.executor.memory does not size them. By default spark.executor.pyspark.memory is unset: Spark does not limit Python's memory, and Python workers share spark.executor.memoryOverhead with other non-JVM memory in the container. On YARN and Kubernetes, a container that exceeds its total can be killed while the heap looks healthy.
Setting spark.executor.pyspark.memory does two things. On YARN and Kubernetes it adds that amount to the executor's resource request. And PythonRunner divides it by the executor's cores and passes the result to each worker, which applies it as an address-space limit through Python's resource module. The configuration reference notes that the limit is not supported on Windows and not enforced on macOS, so it cannot be tested on a Mac laptop.
Two neighbors are easy to confuse. spark.python.worker.memory (512m) only sets when a Python worker spills to disk during aggregation. And Arrow batches take JVM memory while they are built — the guide warns this "can temporarily lead to high memory usage in the JVM." toPandas() collects the whole result to the driver whether or not Arrow is enabled. Executor memory explains how the container total adds up.
Check the sources: PySpark memory settings · PythonRunner · worker.py limits · Arrow batch memory · toPandas
Choose the least opaque tool
- Built-in functions and SQL expressions. Catalyst can optimize them, push eligible predicates, and generate code. Check the built-in list first — string, date, JSON, regex, and conditional logic are mostly covered.
- Higher-order functions such as
transform,filter,exists, andaggregatefor array and map columns. They stay in the JVM with no Python round trip, although in the observed plan theProjectusingtransformfell out of whole-stage codegen. - pandas UDFs or pandas function APIs when the logic genuinely needs Python libraries, written with vectorized operations.
Series.applyinside a pandas UDF is still a Python call per row. - A row-at-a-time Python UDF as the last resort. Consider
useArrow=Trueafter checking that its type coercion gives the results you expect.
An illustration, not a benchmark. On one laptop (local[2], 2,000,000 cached strings, best of three runs of summing the length of upper-cased values), the built-in took 0.15 s, the pandas UDF 0.38 s, the Arrow Python UDF 0.60 s, and the plain Python UDF 1.22 s. Those ratios come from one trivial function on one machine. The gap changes with the function's own cost, data types, batch size, row width, and cluster layout, so there is no general "10–100×" multiplier. Compare plans first, then time a representative sample of your own job.
Check the sources: Built-in functions · Arrow and pandas guide · udf
Try it on a small dataset
Run this in a PySpark notebook with an existing spark session. Create a local session if needed. The example was run for review; this website does not execute Spark.
import pandas as pd
from pyspark.sql import functions as F
people = spark.createDataFrame(
[(1, "ada"), (2, "grace"), (3, None)], "id INT, name STRING"
)
@F.udf("string")
def upper_py(s):
return s.upper() if s is not None else None
@F.udf("string", useArrow=True)
def upper_arrow(s):
return s.upper() if s is not None else None
@F.pandas_udf("string")
def upper_pandas(s: pd.Series) -> pd.Series:
return s.str.upper()
for label, column in [
("built-in", F.upper("name")),
("Python UDF", upper_py("name")),
("Arrow Python UDF", upper_arrow("name")),
("pandas UDF", upper_pandas("name")),
]:
print(label)
people.select("id", column.alias("u")).filter(F.col("id") > 1).explain()The built-in plan was a single codegen stage: *(1) Project [id#0, upper(name#1) AS u#7] over *(1) Filter (isnotnull(id#0) AND (id#0 > 1)) and the scan. The Python UDF plan inserted BatchEvalPython [upper_py(name#1)#4] between *(1) Filter/Scan and *(2) Project. The Arrow Python UDF and the pandas UDF both inserted ArrowEvalPython, with eval types 101 and 200. In every plan the id > 1 filter ran below the UDF. Requires pandas and PyArrow installed; attribute IDs vary between runs.
Ran on local PySpark 3.5.7 (local[2], JDK 17, pandas and PyArrow installed) on 2026-09-15; output above is observed.
Assuming useArrow=True or a pandas UDF makes Python code fast by itself. Arrow changes how batches cross the boundary: an Arrow Python UDF still calls your function once per row, and a pandas UDF whose body uses Series.apply or a Python loop is still per-row Python. Neither lets Catalyst push a predicate through the function or fuse it into generated code. Check the plan for BatchEvalPython or ArrowEvalPython, and time a representative sample before and after the change.
A teammate wraps a string cleanup in @udf, finds it slow, and adds useArrow=True. What changed, and what did not?
Say your answer out loud before opening the notes.
Reveal the reasoning
The transfer changed: rows now reach the Python worker as Arrow batches instead of pickled rows, and the plan shows ArrowEvalPython instead of BatchEvalPython. In Spark 3.5.7 the worker still calls the function once per row, so the per-row Python call remains, and Arrow's return-type coercion can differ from the pickled path, which needs checking. What did not change: the function is still opaque, so a predicate on its result cannot be pushed into the scan and whole-stage codegen still splits around it. If the cleanup exists as built-in functions such as trim, lower, or regexp_replace, use those; if it truly needs Python, move to a pandas UDF with a vectorized body. Confirm with the plan and a timed sample.
Sources & verification
Core claims checked against Apache Spark 3.5.7 documentation and source references on 2026-09-15. Example output observed in a local run: Ran on local PySpark 3.5.7 (local[2], JDK 17, pandas and PyArrow installed) on 2026-09-15; output above is observed. Explanations, scenarios, and diagrams are original teaching material. Other releases and managed runtimes can differ.
- PySpark 3.5.7 user guide: Apache Arrow, pandas UDFs, and Arrow Python UDFs ↗
- PySpark 3.5.7: pyspark.sql.functions.udf (useArrow, determinism notes) ↗
- PySpark 3.5.7: pyspark.sql.functions.pandas_udf ↗
- PySpark 3.5.7: DataFrame.mapInPandas ↗
- PySpark 3.5.7: GroupedData.applyInPandas ↗
- PySpark 3.5.7: DataFrame.toPandas ↗
- Spark 3.5.7 configuration: PySpark memory, overhead, Python workers, Arrow ↗
- EvalPythonExec source (v3.5.7): sending rows to a Python process ↗
- BatchEvalPythonExec source (v3.5.7): pickled row batches ↗
- ExtractPythonUDFs source (v3.5.7) ↗
- PythonRunner source (v3.5.7): eval types and per-worker memory limit ↗
- PySpark worker.py (v3.5.7): Arrow UDF row loop and resource limits ↗
- Spark 3.5.7 SQL built-in functions, including higher-order array functions ↗