Short: Built-in expression, then higher-order functions, then pandas_udf, and only then a plain Python UDF.
Detailed: Built-ins keep codegen and pushdown. Higher-order functions (transform, filter, exists, aggregate) handle array and map logic without leaving SQL. pandas_udf ships columnar Arrow batches, so you pay serialization per batch instead of per row and get vectorized pandas or NumPy code. A row-wise Python UDF is the last resort.
Senior: It is still opaque to Catalyst, so nothing pushes through it, and on Databricks it still knocks the query off Photon. Faster, not free.
Common mistake: Jumping straight to pandas_udf when composing two built-ins would have done it.
Follow-up: What does a pandas UDF still not fix?
Lesson · Simulation