What you will be able to do
- Write an Iterator of Series UDF that initializes expensive state once and reuses it across batches
- Write a UDF that takes several columns through an iterator of tuples of Series
- Use a Series to scalar pandas UDF as an aggregate in groupBy().agg() and over a Window
- Choose the right pandas UDF variant from its type hint and the API you will call it in
1.Iterator of Series to Iterator of Series
A Series to Series UDF does all its work inside one call per batch. If that work needs expensive setup, such as loading a machine learning model, the setup runs again for every batch. The iterator variant avoids this. Spark calls the function once and gives it an iterator of batches. The function yields one output batch for each input batch, so you can do the setup once before the loop. Spark still picks the variant from the type hint, here Iterator[pd.Series] -> Iterator[pd.Series].
@pandas_udf("long")
def plus_one(iterator: Iterator[pd.Series]) -> Iterator[pd.Series]:
for s in iterator:
yield s + 1The length rule is relaxed here, but it still applies. Each yielded batch doesn't have to match the batch it came from, but the total output across all batches must be as long as the total input. To make sure resources acquired before the loop are released, the docs recommend a try/finally block or a context manager:
@pandas_udf("long")
def plus_y(batch_iter: Iterator[pd.Series]) -> Iterator[pd.Series]:
try:
for x in batch_iter:
yield x + y
finally:
pass # release resources here, if anyCheckpoint 1 of 5· Check yourself
An Iterator of Series UDF receives 1,000 input rows split across several batches. Which rule applies to the rows it yields?
The length constraint applies to the entire output versus the entire input, not to each individual batch, so the function has some freedom in how it batches its output.
“The length of the entire output in the iterator should be the same as the length of the entire input.”Source: docs.databricks.com
Sources1
2.Iterator of multiple Series: several input columns
To pass several columns to an iterator UDF, declare the input as an iterator of tuples: Iterator[Tuple[pd.Series, ...]] -> Iterator[pd.Series]. Each element of the iterator is a tuple with one Series per column, all covering the same batch, and you unpack the tuple in the for loop. Everything else is the same as the single-column iterator: you can set up state once, and the total output length must match the total input length.
@pandas_udf("long")
def multiply_two_cols(
iterator: Iterator[Tuple[pd.Series, pd.Series]]) -> Iterator[pd.Series]:
for a, b in iterator:
yield a * b
df.select(multiply_two_cols("x", "x")).show()Checkpoint 2 of 5· Check yourself
You need to load a model once per task and score it against two columns, weight and height. Which signature fits?
Several input columns and one-time setup together mean the iterator of tuples variant. A plain Iterator[pd.Series] accepts only one column, and pd.Series -> float is an aggregate.
“You specify the type hints as Iterator[Tuple[pandas.Series, ...]] -> Iterator[pandas.Series].”Source: docs.databricks.com
Sources1
3.Series to scalar: pandas UDFs as aggregates
Every variant so far returns as many rows as it receives. A Series to scalar UDF returns one value, so it works like a Spark aggregate function. Its type hint is pd.Series, ... -> Any, and in practice you replace Any with a specific scalar type such as float. The returnType must be a primitive data type, and the function can return a Python primitive like int or float or a NumPy scalar like numpy.float64. You can use it in select (aggregating the whole DataFrame), in groupBy().agg(), and over a Window.
@pandas_udf("double")
def mean_udf(v: pd.Series) -> float:
return v.mean()
df = spark.createDataFrame(
[(1, 1.0), (1, 2.0), (2, 3.0), (2, 5.0), (2, 10.0)], ("id", "v"))
df.groupby("id").agg(mean_udf(df['v'])).show()w = Window.partitionBy('id').orderBy('v').rowsBetween(-1, 0)
df.withColumn('mean_v', mean_udf("v").over(w)).show()There is a cost. Built-in aggregates can partially aggregate data before the shuffle, but a Series to scalar pandas UDF can't. All of a group's data is loaded into memory before the function runs, so one very large group can exhaust a worker's memory. Newer Spark releases add an Iterator of Series to scalar form, Iterator[pd.Series] -> float, which Databricks describes as memory-efficient grouped aggregation. It receives a group's data batch by batch and keeps running totals instead of holding the whole group in memory.
@pandas_udf("double")
def pandas_mean_iter(it: Iterator[pd.Series]) -> float:
sum_val = 0.0
cnt = 0
for v in it:
sum_val += v.sum()
cnt += len(v)
return sum_val / cntCheckpoint 3 of 5· Check yourself
A Series to scalar pandas UDF runs in groupBy("customer").agg(...) and fails with out-of-memory errors on one very large customer. What explains it?
Spark can't pre-aggregate parts of a group for this kind of UDF, so a skewed group must fit in memory all at once.
“This type of UDF does not support partial aggregation and all data for each group is loaded into memory.”Source: docs.databricks.com
Checkpoint 4 of 5· Exam question
An analyst wants to compute a weighted average price per `store_id` using a custom pandas UDF and Spark's grouped aggregation, so the UDF should take a Series of values from a group and return a single number for that group. Which definition and call correctly implements this?
Correct answer: A — `@pandas_udf(DoubleType())` on `def weighted_avg(price: pd.Series) -> float: return float(price.mean())`, called as `orders.groupby("store_id").agg(weighted_avg(orders.price))`.
- A. Returning a single float from a Series input is exactly the grouped-aggregate shape pandas_udf expects, so calling it inside groupby("store_id").agg(...) produces one weighted-average value per store as intended.
- B. The function itself is a valid grouped-aggregate UDF, but calling it inside withColumn on an ungrouped DataFrame is not a supported use of a Series-to-scalar pandas UDF, and Spark raises an error because no grouping context exists to collapse into a scalar.
- C. Returning a Series the same length as the input instead of a single float makes this a Series-to-Series pandas UDF, which Spark cannot use inside groupby().agg() because an aggregation call expects one scalar result per group, not one value per row.
- D. Without the pandas_udf decorator, this is treated as a plain Python function rather than a registered Spark UDF, so calling it with a Column argument inside agg() fails instead of computing a per-group average.
- E. The iterator-based signature is meant to be called directly on ungrouped batches of data, not passed to groupby().agg(), so wrapping it in a group aggregation this way does not match any supported pandas_udf calling pattern and raises an error.
4.Choosing the variant from the type hint
All of these variants are created the same way, with pandas_udf and a returnType. They differ only in their Python type hints, and Spark uses those hints to decide how to call the function. The table lists the four variants in the Databricks pandas UDF guide by signature and by where you invoke them.
| Variant | Type hint | Input columns | Output and typical APIs |
|---|---|---|---|
| Series to Series | pandas.Series, ... -> pandas.Series | One or more | Same-length Series; select, withColumn |
| Iterator of Series to Iterator of Series | Iterator[pandas.Series] -> Iterator[pandas.Series] | Exactly one | Same total length; one-time state such as a loaded model |
| Iterator of multiple Series to Iterator of Series | Iterator[Tuple[pandas.Series, ...]] -> Iterator[pandas.Series] | Multiple | Same total length; one-time state across several columns |
| Series to scalar | pandas.Series, ... -> Any | One or more | One primitive value; select, withColumn, groupBy.agg, pyspark.sql.Window |
Checkpoint 5 of 5· Match them up
Match each type hint to the variant Spark infers from it
Tap a term, then the definition that fits it.
Each signature corresponds to exactly one variant. Whether the hint returns a Series or a scalar decides between a per-row and an aggregate UDF, and an Iterator input means the iterator form.
“You express the type hint as pandas.Series, ... -> Any.”Source: docs.databricks.com
Sources1
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.An Iterator[pd.Series] -> Iterator[pd.Series] UDF can take several columns, one per element of the iterator.Why is that wrong?
That signature takes a single Spark column. For several columns, the hint must be an iterator of tuples of Series.
Covered in Iterator of multiple Series: several input columns
2.A Series to scalar pandas UDF partially aggregates like built-in aggregates, so skewed groups are safe.Why is that wrong?
It doesn't support partial aggregation. All of a group's data is loaded into memory before the function runs.
Covered in Series to scalar: pandas UDFs as aggregates
Practise it for real
Create a pandas UDF by explicit wrapping, test it locally, invoke it on a Spark DataFrame, then compare it with a decorator-based aggregate.
1.Define multiply_func(a: pd.Series, b: pd.Series) -> pd.Series that returns a * b, and wrap it with multiply = pandas_udf(multiply_func, returnType=LongType()).
Why: Explicit wrapping keeps the plain function available for local testing and gives you a separate UDF object for Spark.
You should see: multiply is a pandas UDF, and multiply_func is still an ordinary Python function.
2.Call multiply_func(x, x) on x = pd.Series([1, 2, 3]).
Why: The function must work on local pandas data, so you can check its logic without Spark.
You should see: A pandas Series containing 1, 4, 9 with dtype int64.
3.Create df = spark.createDataFrame(pd.DataFrame(x, columns=["x"])) and run df.select(multiply(col("x"), col("x"))).show().
Why: This calls the vectorized UDF on Spark, which runs the function once per Arrow batch.
You should see: A column named multiply_func(x, x) with values 1, 4, 9.
4.Define @pandas_udf("double") def mean_udf(v: pd.Series) -> float: return v.mean(), then run it with df.groupby("id").agg(mean_udf(df['v'])) on the five-row (id, v) DataFrame from the Series to scalar section.
Why: The float return hint turns the same pandas_udf call into an aggregate.
You should see: One row per id: 1.5 for id 1 and 6.0 for id 2.
5.Register a Series to Series UDF with spark.udf.register("add_one", add_one) and run spark.sql("SELECT add_one(id) FROM range(3)").collect().
Why: Registration lets you call the same pandas UDF from SQL.
You should see: Rows with add_one(id) equal to 1, 2 and 3.
Stuck? Get a nudge
If the groupBy step returns one row instead of one per id, check that you called agg on df.groupby("id") rather than using df.select.
Sources
Every claim above is drawn from one of these pages, quoted as it was written on the date shown.
- 1.https://docs.databricks.com/aws/en/udf/pandasOfficial docs
“useful when the UDF execution requires initializing some state, for example, loading a machine learning model file to apply inference to every input batch.”
↩︎ Iterator of Series to Iterator of Series“The length of the entire output in the iterator should be the same as the length of the entire input.”
↩︎ Iterator of Series to Iterator of Series“Wrap your code with try/finally or use context managers to ensure”
↩︎ Iterator of Series to Iterator of Series“The underlying Python function takes an iterator of a tuple of pandas Series.”
↩︎ Iterator of multiple Series: several input columns“You use a Series to scalar pandas UDF with APIs such as select, withColumn, groupBy.agg, and pyspark.sql.Window.”
↩︎ Series to scalar: pandas UDFs as aggregates“The return type should be a primitive data type”
↩︎ Series to scalar: pandas UDFs as aggregates“This article describes the different types of pandas UDFs and shows how to use pandas UDFs with type hints.”
↩︎ Choosing the variant from the type hint“The wrapped pandas UDF takes multiple Spark columns as an input.”
↩︎ Exam trap 1“This type of UDF does not support partial aggregation and all data for each group is loaded into memory.”
↩︎ Exam trap 2“The wrapped pandas UDF takes a single Spark column as an input.”
↩︎ Prediction“You specify the type hints as Iterator[Tuple[pandas.Series, ...]] -> Iterator[pandas.Series].”
↩︎ Checkpoint“This type of UDF does not support partial aggregation and all data for each group is loaded into memory.”
↩︎ Checkpoint“You express the type hint as pandas.Series, ... -> Any.”
↩︎ Checkpoint - 2.
“Example 6: Iterator of Series to Scalar - Memory-efficient grouped aggregation.”
↩︎ Series to scalar: pandas UDFs as aggregates