A PySpark job spends most of its time in one stage, CPU is busy on the executors, and the Spark UI shows nothing unusual: no skew, no spill, small shuffles. The stage contains a Python UDF. Every row leaves the JVM, gets pickled, crosses a socket to a Python worker, runs through the interpreter and comes back. For simple logic, that serialization costs more than the work.
This guide shows how to find these UDFs, then replaces them in order of preference.
Find the Python UDFs in the plan
Run explain on the DataFrame that feeds the slow write:
df_out.explain(mode="formatted")
Look for these nodes:
| Plan node | Meaning |
|---|---|
BatchEvalPython | Classic pickled Python UDF, row at a time |
ArrowEvalPython | pandas UDF or Arrow-optimized UDF, columnar batches |
MapInPandas / MapInArrow | mapInPandas / mapInArrow |
FlatMapGroupsInPandas | groupBy().applyInPandas() |
Two more costs that are easy to miss:
- No pushdown through a UDF. A
filter(my_udf(col("x")))cannot be pushed to Parquet or Delta, so Spark reads every row first. Check whetherPushedFiltersin the scan node is empty. - No whole-stage codegen across it. Operators on either side of the Python node run in separate stages of evaluation, and Spark may evaluate the UDF more than once if the column is referenced several times.
Option 1: replace the UDF with built-in functions
Most UDFs in production are string cleanup, date parsing, conditional mapping or JSON extraction, all of which exist as built-ins and run inside the JVM with codegen.
# Before: row-at-a-time UDF
from pyspark.sql.functions import udf
from pyspark.sql.types import StringType
@udf(StringType())
def normalise_phone(p):
if p is None:
return None
digits = "".join(c for c in p if c.isdigit())
return digits[-10:] if len(digits) >= 10 else None
# After: built-ins, no Python worker involved
from pyspark.sql import functions as F
digits = F.regexp_replace(F.col("phone"), r"[^0-9]", "")
df = df.withColumn(
"phone_norm",
F.when(F.length(digits) >= 10, F.substring(digits, -10, 10))
)
Useful replacements:
| UDF pattern | Built-in |
|---|---|
| Dictionary lookup | F.create_map(...)[col], or a broadcast join to a small lookup table |
| Parse JSON field | F.get_json_object, F.from_json with a schema |
| Loop over an array | Higher-order functions: F.transform, F.filter, F.aggregate, F.exists |
| Date arithmetic | F.date_add, F.months_between, F.date_trunc, F.make_date |
| Hashing | F.sha2, F.xxhash64 |
| Complex expression reused across jobs | SQL functions in the catalog (for example Unity Catalog SQL UDFs, which are inlined) |
Tip
Test that the replacement returns the same results before switching. Run both versions on a sample and compare with
exceptAllin both directions. Null handling and Unicode digits are where they usually differ.
Option 2: Arrow-optimized Python UDFs (Spark 3.5+)
When the logic really needs Python but is still row-at-a-time, Spark 3.5 added Arrow-optimized UDFs. The function body is unchanged; data moves in Arrow batches instead of pickled rows.
@udf(returnType=StringType(), useArrow=True)
def classify(text):
...
Or for the session, which applies to UDFs that do not set useArrow explicitly:
spark.conf.set("spark.sql.execution.pythonUDF.arrow.enabled", "true")
The default for this setting has been changing between releases; check your version's docs or spark.conf.get rather than assuming. Type coercion differs slightly from pickled UDFs (for example how mismatched return types are handled), so run your tests after turning it on.
Do not confuse it with spark.sql.execution.arrow.pyspark.enabled, which controls toPandas() and createDataFrame(pandas_df), not UDFs.
Option 3: pandas UDFs for vectorised logic
If the work can be expressed with pandas or NumPy over a whole column, a pandas UDF processes a batch at a time with vectorised code.
import pandas as pd
from pyspark.sql.functions import pandas_udf
@pandas_udf("double")
def haversine_km(lat1: pd.Series, lon1: pd.Series,
lat2: pd.Series, lon2: pd.Series) -> pd.Series:
import numpy as np
r = 6371.0
p1, p2 = np.radians(lat1), np.radians(lat2)
dphi = p2 - p1
dlmb = np.radians(lon2 - lon1)
a = np.sin(dphi/2)**2 + np.cos(p1)*np.cos(p2)*np.sin(dlmb/2)**2
return pd.Series(2 * r * np.arcsin(np.sqrt(a)))
df = df.withColumn("km", haversine_km("lat1", "lon1", "lat2", "lon2"))
For expensive set-up such as loading a model or opening a client, use the iterator form so it runs once per partition rather than once per batch:
from typing import Iterator
@pandas_udf("string")
def predict(batches: Iterator[pd.Series]) -> Iterator[pd.Series]:
model = load_model("/dbfs/models/classifier.pkl") # once per Python worker task
for s in batches:
yield pd.Series(model.predict(s.to_frame()))
Batch size is controlled by spark.sql.execution.arrow.maxRecordsPerBatch (default 10,000). Lower it for wide rows or memory-hungry models; raise it carefully for narrow numeric columns.
Option 4: mapInPandas and applyInPandas
mapInPandas hands each partition to Python as a stream of DataFrames; use it when output rows do not map one-to-one to input rows.
groupBy(...).applyInPandas(fn, schema) is the one that causes outages. Each group is loaded fully into the Python worker's memory, regardless of batch size. One customer with 50 million rows fails the job.
# Check the largest groups before using applyInPandas
(df.groupBy("customer_id").count()
.orderBy(F.desc("count")).show(10))
If the top group is large, pre-aggregate in Spark, split the key (for example by date) or rewrite the per-group logic with window functions.
Memory: Python workers live outside the JVM heap
Python worker memory is not part of spark.executor.memory. On YARN and Kubernetes it comes out of the memory overhead, so heavy pandas UDFs produce container kills ("exceeding memory limits", exit code 137 or OOMKilled) rather than Java OOM errors.
| Setting | Purpose |
|---|---|
spark.executor.memoryOverhead | Off-heap and Python worker headroom per executor |
spark.executor.pyspark.memory | Caps Python worker memory per executor; when set, added to the container request |
spark.sql.execution.arrow.maxRecordsPerBatch | Smaller batches, less memory per worker |
spark.executor.cores | Each concurrent task can have its own Python worker; fewer cores means fewer workers sharing the overhead |
spark-submit \
--conf spark.executor.memory=8g \
--conf spark.executor.memoryOverhead=4g \
--conf spark.executor.pyspark.memory=3g \
--conf spark.sql.execution.arrow.maxRecordsPerBatch=2000 \
job.py
Platform-managed environments such as Databricks may size overhead for you; check your platform's documentation before overriding it.
Warning
On shared or Unity Catalog standard access mode clusters, Python UDFs run in a sandbox with extra restrictions and overhead. Check the access mode limitations for your runtime before moving UDF-heavy jobs onto shared compute.
Measure the change
Compare before and after on the same input with the stage time in the Spark UI, or time it directly:
import time
t = time.time()
df_out.write.format("noop").mode("overwrite").save()
print(f"{time.time() - t:.1f}s")
The noop format runs the full plan without writing output, so you measure compute, not storage.
Prevent it next time
- Add a review rule: a new Python UDF needs a short note on why built-ins do not work.
- Search the codebase for
@udfandudf(periodically and track the count. - Use
explainin CI for key jobs and fail on unexpectedBatchEvalPythonnodes. - Check group size distribution before any
applyInPandasgoes to production, and alert on it if keys can grow.