What you will be able to do
- Decide when a UDF is the right tool and when built-in Spark functions are better
- Register a scalar Python UDF with spark.udf.register, udf() or the @udf decorator, with an explicit return type
- Write null-safe UDF calls that do not depend on SQL short-circuiting
- Choose between Series to Series, iterator, and Series to scalar pandas UDFs
Key concept
UDF scope: session-scoped vs Unity Catalog-governed — A UDF is either defined in your notebook or SparkSession and lives only there, or it is registered as a Unity Catalog function that is governed and shared. The two kinds have different scope, different features and different compute support, so you need to know which kind a question is about before you can answer it.
1.When a UDF is the right tool
A user-defined function lets you reuse custom logic that Spark does not provide out of the box. Databricks is clear that this has a cost. Built-in Apache Spark functions are optimized for distributed processing and perform better at scale, so use a UDF only for logic that is hard to express with them. The recommended uses are ad hoc queries, manual data cleaning, exploratory analysis and small to medium-sized datasets. Typical jobs are encryption, decryption, hashing, JSON parsing and validation. For large datasets and for anything that runs regularly or continuously, such as ETL jobs and streaming, Databricks recommends native Spark methods instead.
Every UDF type is defined by two things: how many rows go in and come out, and where the function lives. A UC-governed function is registered in Unity Catalog and shared under its permissions. A session-scoped function exists only in the notebook or SparkSession that defined it. This page covers the session-scoped Python and pandas UDFs you write in PySpark.
| UDF type | Input → output | Scope |
|---|---|---|
| Scalar UDF | One row in, one value out | Unity Catalog governed or session-scoped |
| Batch scalar UDF | Batches of rows, 1:1 input/output row parity | Unity Catalog governed or session-scoped |
| Non-scalar (pandas) UDF | Whole columns, flexible ratios (1:N or many:many) | Session-scoped batch pandas UDFs |
| UDAF | Many rows in, one aggregated result | Session-scoped only |
| UDTF | Multiple rows (and possibly columns) per input row | Unity Catalog governed or session-scoped |
Checkpoint 1 of 7· Check yourself
A team wants every analyst in the workspace to call its custom aggregation as a governed Unity Catalog function. According to the UDF type overview, which kind of UDF can never be Unity Catalog governed?
Scalar, batch scalar and table functions can be governed by Unity Catalog or session-scoped. User-defined aggregate functions exist only at session scope.
“UDAFs are session-scoped only.”Source: docs.databricks.com
Sources1
2.Scalar Python UDFs: three ways to declare them
A scalar Python UDF runs one row at a time. You can declare it in three ways. Use spark.udf.register to make it callable by name from SQL, for example from a %sql cell against a temp view. Use udf(func, returnType) to wrap a function for the DataFrame API. Or put the @udf("long") decorator on the function. In every case the return type is optional, and if you leave it out you get StringType. That is easy to miss when a numeric function quietly returns strings.
from pyspark.sql.types import LongType
def squared_typed(s):
return s * s
spark.udf.register("squaredWithPython", squared_typed, LongType())from pyspark.sql.functions import udf
@udf("long")
def squared_udf(s):
return s * s
df = spark.table("test")
display(df.select("id", squared_udf("id").alias("id_squared")))Checkpoint 2 of 7· Fill the gap
Complete the udf() call so that the squared values come back as 64-bit integers instead of the default type.
from pyspark.sql.functions import udf
from pyspark.sql.types import LongType
squared_udf = udf(squared, ? )
df = spark.table("test")
display(df.select("id", squared_udf("id").alias("id_squared")))The second argument to udf() is the return type, passed as an instance: LongType(). If you leave it out, the UDF returns StringType.
Source: docs.databricks.comCheckpoint 3 of 7· Exam question
A data engineering team calls a Python UDF that scores millions of rows using a machine learning model loaded from a serialized file. Loading the model takes several seconds, and the current scalar `pandas_udf` reloads the model on every batch, causing high per-partition overhead. Which pandas UDF design lets the model load exactly once per Python worker process while still returning one score for every input row?
Correct answer: A — Rewrite the function as an Iterator of Series to Iterator of Series pandas UDF, loading the model once before iterating over the batches.
- A. An Iterator of Series to Iterator of Series pandas UDF receives an iterator over all the batches assigned to a worker, so setup code such as loading the serialized model runs once before the loop and is reused across every batch. It still returns exactly one Series of scores per input Series, preserving the one-score-per-row contract.
- B. `groupBy().applyInPandas()` invokes the function once per distinct group key, so with many groups the model would be reloaded repeatedly, which does not solve the reload overhead and also changes the row-count semantics unnecessarily.
- C. `mapInPandas()` operates on iterators of pandas DataFrames per partition without guaranteeing a matching row count between input and output, so it is designed for row-count-changing transformations rather than a strict one-score-per-row scoring UDF.
- D. A scalar pandas UDF is re-invoked for each batch, and `lru_cache` keys its cache on the function's arguments; since the batch data differs on every call, the cache would never hit and the model would still be reloaded.
Sources2
3.Null handling and evaluation order
Spark SQL, including the DataFrame API, does not fix the order in which an operator's inputs are evaluated. Boolean AND and OR do not short-circuit from left to right, and the query optimizer can reorder WHERE and HAVING clauses. A null check written to the left of a UDF call therefore protects nothing. There are two safe patterns. Either make the UDF itself null-aware, or put the call inside an IF or CASE WHEN branch that only runs when the value is not null.
spark.udf.register("strlen_nullsafe", lambda s: len(s) if not s is None else -1, "int")
spark.sql("select s from test1 where s is not null and strlen_nullsafe(s) > 1") # ok
spark.sql("select s from test1 where if(s is not null, strlen(s), null) > 1") # okCheckpoint 4 of 7· Check yourself
A UDF fails intermittently with a TypeError on NULL input, even though the query filters col IS NOT NULL before calling it. Which change is the recommended fix?
The optimizer can reorder WHERE predicates, so only a null-aware UDF or a conditional branch guarantees safety.
“Make the UDF itself null-aware and do null checking inside the UDF itself”Source: docs.databricks.com
Sources2
4.pandas UDFs: vectorized batches over Arrow
A pandas UDF, also called a vectorized UDF, removes the row-at-a-time overhead. Spark uses Apache Arrow to move data in batches, and your function works on those batches with pandas. You declare one with pandas_udf, as a decorator or a wrapper, and the Python type hints tell Spark which of four forms you are writing. The simplest form is Series to Series. Spark splits the data into batches, calls your function once per batch, and concatenates the results. The output Series must be the same length as the input.
# Declare the function and create the UDF
def multiply_func(a: pd.Series, b: pd.Series) -> pd.Series:
return a * b
multiply = pandas_udf(multiply_func, returnType=LongType())The iterator forms take an iterator of batches and yield an iterator of batches, and the total output length must still match the input. They exist so you can do expensive setup, such as loading a model, once before the loop instead of once per batch. Use try/finally or a context manager to release resources at the end. Iterator of Series takes one column. Iterator of multiple Series takes a tuple of Series per batch, one for each input column. Series to scalar is the odd one out: it works like a Spark aggregate function and can be used with select, withColumn, groupBy.agg and Window. It does not support partial aggregation, so all the data for each group is loaded into memory.
@pandas_udf("long")
def plus_one(batch_iter: Iterator[pd.Series]) -> Iterator[pd.Series]:
for x in batch_iter:
yield x + 1| Form | Type hint | Pick it when |
|---|---|---|
| Series to Series | pandas.Series, ... -> pandas.Series | Vectorizing a scalar operation in select or withColumn |
| Iterator of Series to Iterator of Series | Iterator[pandas.Series] -> Iterator[pandas.Series] | One input column plus one-time state initialization |
| Iterator of multiple Series to Iterator of Series | Iterator[Tuple[pandas.Series, ...]] -> Iterator[pandas.Series] | Several input columns plus one-time state initialization |
| Series to scalar | pandas.Series, ... -> Any | An aggregation with groupBy.agg or a Window |
Checkpoint 5 of 7· Match them up
Match each requirement to the pandas UDF form it calls for.
Tap a term, then the definition that fits it.
The iterator forms are for one-time state initialization: the single-Series iterator takes one column and the tuple iterator takes several. Series to scalar aggregates, and Series to Series vectorizes a scalar operation.
“This pandas UDF is useful when the UDF execution requires initializing some state, for example, loading a machine learning model file”Source: docs.databricks.com
Checkpoint 6 of 7· Check yourself
A Series to scalar pandas UDF used in groupBy().agg() runs out of memory on a heavily skewed key. What explains this?
Without partial aggregation, each group is materialized in full before the function runs, so a skewed group can exhaust memory.
“This type of UDF does not support partial aggregation and all data for each group is loaded into memory.”Source: docs.databricks.com
Checkpoint 7 of 7· Exam question
A team runs `sales_df.groupBy("region").applyInPandas(compute_stats, schema=result_schema)` to execute a custom Python function that computes a variable number of summary rows per region using pandas operations that are not expressible in Spark SQL. Which two statements correctly describe how `applyInPandas` behaves in this scenario?(Select 2)
Correct answers: A, B — `compute_stats` receives one region's full set of rows as a single pandas DataFrame and may return a DataFrame with a different row count than it received.; The `schema` argument must declare the output columns and types up front, since Spark cannot infer the result schema from arbitrary pandas code before running it.
- A. This matches the split-apply-combine model of grouped map pandas functions: each group's rows, including the grouping column values, arrive as one pandas DataFrame, and the function's returned DataFrame can have more or fewer rows than the group it was given.
- B. Because the function body is arbitrary Python executed lazily on workers, Spark has no way to introspect the resulting columns and types ahead of time, so the schema must be supplied explicitly for query planning.
- C. The full set of columns for the group, not just the grouping keys, is passed into the function, so aggregation logic over value columns such as sales totals works normally inside the function body.
- D. Grouped map functions are explicitly allowed to change the row count per group, unlike a scalar pandas UDF which must return one output value for each input value; requiring a fixed row count would defeat the purpose of custom per-group aggregation.
- E. `applyInPandas` is only available on a `GroupedData` object produced by calling `groupBy` first; it does not perform its own grouping and cannot be attached directly to an ungrouped DataFrame.
- F. The grouping column values remain part of the pandas DataFrame handed to the function rather than being dropped, so the function can read or re-emit them without needing an outer closure.
Sources3
Exam traps
Each one states something that sounds right. Open it to see what is actually true.
1.A Python UDF's return type is inferred from what the function returns.Why is that wrong?
Nothing is inferred. If you don't pass a return type, the UDF returns StringType.
2.Putting
s IS NOT NULL ANDbefore a UDF call protects the UDF from NULLs.Why is that wrong?Spark SQL fixes no evaluation order, and AND does not short-circuit. Do the null check inside the UDF or in an IF/CASE WHEN branch.
Covered in Null handling and evaluation order
3.A Series to scalar pandas UDF aggregates incrementally like a built-in sum.Why is that wrong?
It does not partially aggregate. Each group's data is loaded into memory in full.
Covered in pandas UDFs: vectorized batches over Arrow
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/udfOfficial docs
“Use Apache Spark methods for operations on large datasets and any workloads that run regularly or continuously, including ETL jobs and streaming operations.”
↩︎ When a UDF is the right tool“Use UDFs for logic that is difficult to express with built-in Apache Spark functions.”
↩︎ When a UDF is the right tool“UDAFs are session-scoped only.”
↩︎ Checkpoint - 2.https://docs.databricks.com/aws/en/udf/pythonOfficial docs
“You can optionally set the return type of your UDF. The default return type is StringType.”
↩︎ Scalar Python UDFs: three ways to declare them“Use IF or CASE WHEN expressions to do the null check and invoke the UDF in a conditional branch”
↩︎ Null handling and evaluation order“The default return type is StringType.”
↩︎ Exam trap 1“For example, logical AND and OR expressions do not have left-to-right”
↩︎ Exam trap 2“This WHERE clause does not guarantee the strlen UDF to be invoked after filtering out nulls.”
↩︎ Prediction“Make the UDF itself null-aware and do null checking inside the UDF itself”
↩︎ Checkpoint - 3.https://docs.databricks.com/aws/en/udf/pandasOfficial docs
“pandas UDFs allow vectorized operations that can increase performance up to 100x compared to row-at-a-time Python UDFs.”
↩︎ pandas UDFs: vectorized batches over Arrow“You should specify the Python type hint as Iterator[pandas.Series] -> Iterator[pandas.Series].”
↩︎ pandas UDFs: vectorized batches over Arrow“This type of UDF does not support partial aggregation”
↩︎ Exam trap 3“This pandas UDF is useful when the UDF execution requires initializing some state, for example, loading a machine learning model file”
↩︎ Checkpoint“This type of UDF does not support partial aggregation and all data for each group is loaded into memory.”
↩︎ Checkpoint
Also cited
- https://docs.databricks.com/aws/en/udf/unity-catalogOfficial docs
“Python UDFs registered as functions in Unity Catalog differ in scope and support from PySpark UDFs scoped to a notebook or SparkSession.”
↩︎ Key concept