Registering UDFs in SQL
Spark tutorial · PySpark.in
Registering UDFs in SQL
Overview:
Sometimes the built-in functions are not enough to express the logic you need. Spark SQL allows you to create User Defined Functions (UDFs) custom functions in Python (or other languages) that you can register and call from your SQL queries. UDFs let you extend Spark’s functionality by injecting your own code for row-by-row processing. However, it’s important to use UDFs judiciously: they run outside of Spark’s Catalyst optimizer and can be slower than built-ins. In this tutorial, we’ll show how to create and register UDFs in PySpark and use them in Spark SQL, as well as discuss performance considerations and best practices.
What is a UDF?
A UDF in Spark is simply a function that you write which transforms one or more columns of a row into a single value (per row). For example, you might write a Python function to classify text, or to perform a complex calculation that Spark doesn’t have a built-in for. Once you register this function with Spark, you can call it in SQL queries as if it were a native function.
Registering a Python Function as a UDF: In PySpark, the typical way to register a UDF is using spark.udf.register. You provide a name for the UDF (this is the name you’ll use in SQL), the function itself, and optionally the return type. Let’s go through an example step-by-step:
Suppose we want a UDF to square a number (a trivial example, but it illustrates the mechanics). First, we define the Python function:
```
# Define a Python function
def squared(x):
return x * x
from pyspark.sql import SparkSession
from pyspark.sql.types import IntegerType
# Initialize a SparkSession
spark = SparkSession.builder \
.appName("SparkSQLBasics") \
.getOrCreate()
# Example: creating a DataFrame (this could also be loaded from a file or other source)
data = [("Michael", 29), ("Andy", 30), ("Justin", 19)]
columns = ["name", "age"]
people_df = spark.createDataFrame(data, schema=columns)
people_df.createOrReplaceTempView("people")
# Register the function as "square" UDF returning an Integer
spark.udf.register("square", squared, IntegerType())
spark.sql("SELECT name, age, square(age) AS age_squared FROM people").show()
```
Here, "square" is the name we assign to the UDF in the SQL namespace. We also specified the return type as IntegerType() since squaring an integer yields an integer. If you don’t specify a return type, Spark will assume the UDF returns a string type by default, which can lead to unexpected results (e.g., numeric output might get turned into a string). So it’s a good practice to explicitly set the return type unless your UDF naturally returns a string. Now the UDF is registered and can be used in SQL queries just like a built-in function.
Let’s use the newly registered UDF in a SQL query. We’ll apply square() to a column. For a demo, we can use it on the age column of our people view:
We see the UDF square(age) was applied to each row’s age to produce age_squared. Spark handled the distribution of this function across the data for us. Under the hood, when we called spark.udf.register(...), Spark serialized our Python function and sent it to all the worker nodes so that each partition of data can use it.
A few important points about UDFs:
- You can register lambda functions directly as well. For instance: spark.udf.register("doubleAge", lambda x: x * 2, IntegerType()) would create a UDF that doubles a number.
- The UDF can take multiple columns as inputs too. For example, if you had def concat_name_age(name, age): return f"{name}:{age}", you could register it to take two arguments and return a string.
- UDFs can return complex types (arrays, structs) by specifying appropriate return DataTypes, but that’s more advanced usage.
Using UDFs in DataFrame API vs SQL: We used spark.udf.register to make the UDF available in SQL queries. You can also use UDFs with the DataFrame API by creating a UDF object via pyspark.sql.functions.udf. For example:
```
from pyspark.sql.types import IntegerType
from pyspark.sql.functions import udf
def squared(x):
if x is None:
return None
return x * x # Correct indentation
square_udf = udf(squared, IntegerType()) # Using the function instead of lambda
people_df = people_df.withColumn("age_squared", square_udf("age"))
people_df.show()
```
This does the same thing within the DataFrame DSL. The difference is that spark.udf.register("square", ...) directly registers it for SQL use (and also returns a UDF object if you want to assign it to a variable). In fact, spark.udf.register is convenient because it allows the UDF to be used in both SQL and DataFrame expressions . In our example above, we could also do:
```
from pyspark.sql.functions import col
# Using the registered UDF in DataFrame API via the returned object
square_func = spark.udf.register("square", squared, IntegerType())
people_df.select("name", "age", square_func(col("age")).alias("age_squared")).show()
```
This would yield the same result as using spark.sql.
Performance Considerations and Best Practices:
While UDFs give you a lot of flexibility, they come with performance drawbacks compared to using built-in functions:
- No Catalyst Optimization: Spark treats UDFs as a black box. Catalyst cannot optimize the internals of your UDF. For example, if your UDF is square(x), Spark doesn’t know that it could, say, vectorize that or push part of it down to the data source. It simply has to execute your Python code for each row.
- Row-by-Row Execution: A standard (scalar) Python UDF in PySpark will execute for each row, one by one (on each partition). This is inherently slower than operations that can be done in a parallel vectorized manner in JVM. There is overhead to call back and forth between the Spark engine (JVM) and Python for every row.
- Serialization Overhead: When using Python UDFs, data needs to be converted from Spark’s internal representation to Python objects, the UDF runs, then results converted back. This serialization and deserialization for every row can significantly impact performance. Essentially, you lose some of the benefit of Spark's optimized engine by doing work in Python space.
Given these considerations, it’s best to avoid UDFs unless absolutely necessary. Always check if Spark provides a built-in expression that can achieve the same result. For example, don’t write a Python UDF to compute string length – use the built-in LENGTH() function. Built-in functions run within the Spark engine and are highly optimized.
If you do need complex logic, consider if it can be expressed by combining built-in functions or SQL constructs. If not, and you must use a UDF, here are some best practices:
Use the smallest possible data types for UDF input/output to reduce serialization cost. For instance, if you only need to return an integer between 0-100, returning an IntegerType is better than returning a huge string representation.
Handle nulls inside UDF gracefully. Spark will pass None for null values to your Python function. For example, we might safeguard our squared UDF:
```
hide
def squared(x):
if x is None:
return None
return x * x
```
This prevents errors if a null sneaks in, and just returns null for that entry.
- Register UDF once and reuse it. Don’t re-register inside a loop or repeatedly, as that overhead can add up.
- Consider Pandas UDFs (a.k.a. Vectorized UDFs) if you are on Spark 2.3+ and dealing with large data. Pandas UDFs use Apache Arrow to batch data and operate on Pandas Series, which can be much faster (up to 100x in some cases) than row-by-row UDFs. They still aren’t optimized by Catalyst, but they mitigate the Python overhead by vectorizing the operation. Writing a Pandas UDF is a bit different (using @pandas_udf decorator) and out of scope for this tutorial, but it’s worth exploring for heavy use cases.
- Alternative: Spark native or SQL functions – In some scenarios, writing a custom transformation in Scala/Java (which can be integrated as a UDF in Spark but running on the JVM) is an option to keep performance high. If you’re constrained to PySpark only, then Pandas UDFs or splitting the problem into multiple Spark SQL steps might achieve the result without a Python UDF.
To reiterate, a Python UDF can be extremely useful to plug in custom logic, but use it sparingly. The Spark docs and experts often mention the drawbacks: Python UDFs operate one row at a time, can’t be optimized by Catalyst, and incur significant inter-process communication overhead. Only resort to UDFs for tasks that cannot be done with built-ins or when prototyping something quickly.
Example Recap: As a final example, let’s say we want a UDF to say hello to a name. It’s unnecessary, but just to show a string UDF:
```
def say_hello(name):
return f"Hello, {name}!"
spark.udf.register("sayHello", say_hello)
spark.sql("SELECT name, sayHello(name) AS greeting FROM people").show()
```
This tiny example shows how the UDF sayHello was used to generate a greeting. Internally, Spark turned each row’s name into a Python string, our function ran, and returned a new string.
While the above is straightforward, imagine a more complex UDF – always remember to weigh if the convenience is worth the performance cost. Often, you might find a combination of built-in functions can do the job. For instance, the greeting could be done with CONCAT("Hello, ", name, "!") in pure Spark SQL without any UDF.
More Spark tutorials
- about pyspark
- text diagram
- Apache Spark Runtime Architecture
- Introduction to RDD
- Actions vs Transformations
- Lazy Evaluation in PySpark
All tutorials · Try the free PySpark compiler · Practice challenges