This episode covers how to extend Spark: writing UDFs, UDAFs, and UDTs in Scala, Java, and Python, custom serialization with Kryo, extending Spark with external libraries and user code, and packaging libraries so they can be reused across many jobs.

Spark's built-in features are very rich, but the real world always has special needs: business logic that doesn't exist in the standard functions, data formats Spark doesn't recognize, or aggregations that don't fit common patterns. Episode 17 covers how to extend Spark — not by modifying it, but by adding capabilities through UDFs, UDAFs, UDTs, and custom libraries.
Writing extensions correctly makes pipelines cleaner, because the same logic is written once and used everywhere. Used wrongly, however, they produce slow jobs because they bypass Catalyst optimization.
This episode covers four areas: writing UDFs in Scala, Java, and Python; UDAFs and UDTs; custom serialization with Kryo; and packaging libraries for reuse.
Python is the easiest to start with. Register an ordinary function with spark.udf.register or use the decorator:
from pyspark.sql import functions as F
@F.udf("string")
def format_gaji(gaji):
return f"Rp{gaji:,.0f}"
df.select(format_gaji(df.gaji).alias("gaji_terformat")).show()@F.udf("string") registers the function with a string result type. Keep in mind: Python UDFs run in a separate Python process and data is moved between the JVM and Python — for large data, this cost is real. Prioritize built-in functions or Scala UDFs when performance is critical.
Scala UDFs run inside the JVM, so they're faster:
import org.apache.spark.sql.functions.udf
val formatGaji = udf { (gaji: Double) => f"Rp$gaji%,.0f" }
df.withColumn("gaji_terformat", formatGaji(df("gaji")))udf { (gaji: Double) => ... } creates a UDF from a lambda. Because it executes in the JVM, there's no cross-process transfer overhead — the main advantage over Python UDFs.
Java uses the UDF1<T, R> functional interface approach and requires explicitly registering the function with the SparkSession. The syntax is more verbose, but it produces functions fully integrated with the Java API.
A UDAF combines many rows into a single value using a buffer that can be merged in parallel. In Scala, implement the Aggregator trait:
import org.apache.spark.sql.Encoder
import org.apache.spark.sql.expressions.Aggregator
case class BufferRata2(jumlah: Double, count: Long)
object Rata2Tertimbang extends Aggregator[(Double, Double), BufferRata2, Double] {
def zero: BufferRata2 = BufferRata2(0.0, 0L)
def reduce(b: BufferRata2, input: (Double, Double)): BufferRata2 =
BufferRata2(b.jumlah + input._1 * input._2, b.count + input._2.toLong)
def merge(b1: BufferRata2, b2: BufferRata2): BufferRata2 =
BufferRata2(b1.jumlah + b2.jumlah, b1.count + b2.count)
def finish(b: BufferRata2): Double = if (b.count == 0) 0.0 else b.jumlah / b.count
def bufferEncoder: Encoder[BufferRata2] = Encoders.product
def outputEncoder: Encoder[Double] = Encoders.scalaDouble
}reduce combines rows into the buffer, merge combines buffers across partitions, and finish produces the final output. Because buffers can be merged in parallel, the aggregation stays distributed.
A UDT (User-Defined Type) tells Spark how to store a special object type in a column. It suits domain types like geometry or special vectors. Implementing one requires UserDefinedType and registration setup — used less often than UDFs, but important for domain types.
Java's default serialization is safe but slow. Kryo is far faster and more memory-efficient. Enable it and register your custom classes:
spark.serializer org.apache.spark.serializer.KryoSerializer
spark.kryo.registrationRequired true
spark.kryo.classesToRegister com.example.Pegawai,com.example.Pesananspark.kryo.classesToRegister registers the classes to be serialized. With registrationRequired=true, Spark rejects unregistered classes — this maintains stability because the serialization format doesn't change silently when new classes appear.
Without registration, Kryo uses dynamic class-name mapping that can differ between library versions — a source of hard-to-trace bugs. Explicit registration makes the serialization format stable and is also faster because numeric IDs replace string names.
So your UDFs and UDAFs can be used across many jobs, collect them into a single library and build it with a build tool:
sbt packageThe result is a jar file. Ship the library with the job via spark-submit:
spark-submit --jars spark-udf-lib.jar job.pyFor Scala, combine dependencies into a single jar with --packages or use sbt-assembly for a fat jar. Make sure the library version matches the Spark version — version mismatches are the classic cause of NoSuchMethodError on a cluster.
Recommended practices:
--jars is better than duplicating the library in every job.Tip
Before writing a UDF, always ask: can this be expressed with the built-in functions or SQL? Collections like array_agg, map, and struct in Spark SQL already cover many cases that used to require a UDF. Write a UDF only for logic that genuinely isn't built in.
Episode 17 empowers you to extend Spark: UDFs add per-row functions in Python, Scala, and Java; UDAFs create custom aggregations that stay distributed; Kryo speeds up serialization; and clean packaging makes libraries reusable across jobs.
Key takeaways:
--jars or --packages.In the next episode, episode 18, we'll discuss cross-system integration — connecting Spark to Kafka, Cassandra, and Elasticsearch, Spark's role as an ETL engine for data lakes and warehouses, BI tool integration, and data ingestion and CDC patterns.