Pair your devices with a code and playback position follows you: pause on this device, hit resume on the other. Position is saved to the site every minute and on pause.
Open this panel on your other device and enter the same code.
Starts this lesson and continues through 7 more to the end of the course.
When you need a transformation, there is a strict preference order, and it is about the boundary from lesson 1:
1. Built-in function → JVM only. No crossing. Optimiser can see it.
2. Higher-order / SQL expr → still JVM.
3. pandas UDF (vectorised) → crosses, but in Arrow batches
4. Plain Python UDF → crosses, row by row
5. RDD mapPartitions → crosses, and you lose the optimiser entirely
Always exhaust level 1 before descending. The pyspark.sql.functions module is large and people
reach past it constantly — regexp_extract, split, explode, transform, aggregate,
from_json, date_trunc, coalesce, when/otherwise cover an enormous amount of what people write
UDFs for.
Three costs, and you should be able to name them:
⚠️ Point 3 is the one people miss, and it's often the biggest. A UDF in a filter prevents
predicate pushdown — so instead of the scan skipping partitions, Spark reads everything and then
filters in Python. The UDF's own cost is dwarfed by the data you now read unnecessarily.
# BAD: opaque predicate, no pushdown, reads everything
df.filter(is_valid_region_udf(col("region")))
# GOOD: the optimiser can see this and push it to the scan
df.filter(col("region").isin("EU", "US", "APAC"))
"Apache Arrow is an in-memory columnar data format that is used in Spark to efficiently transfer data between JVM and Python processes."
The documented configuration keys (Apache Arrow in PySpark, verified 2026-08-12):
| Key | Documented behaviour |
|---|---|
spark.sql.execution.arrow.pyspark.enabled |
Arrow optimisation for conversion to/from pandas. Default true in the current docs |
spark.sql.execution.arrow.pyspark.fallback.enabled |
Optimisations "could fallback automatically to non-Arrow optimization implementation if an error occurs before the actual computation within Spark" |
spark.sql.execution.arrow.maxRecordsPerBatch |
Max rows per Arrow batch. Default 10,000 |
spark.sql.execution.arrow.pyspark.selfDestruct.enabled |
Enables PyArrow's self_destruct, which "can save memory when creating a Pandas DataFrame via toPandas" |
spark.sql.execution.pythonUDF.arrow.enabled |
Arrow optimisation for regular Python UDFs, session-wide |
spark.sql.session.timeZone |
"will default to the JVM system local time zone if not set" |
⚠️ Version sensitivity — read this before you copy a config from a blog post. These defaults have changed across Spark releases. The current documentation states Arrow is enabled by default for pandas conversion, and Arrow optimisation for regular Python UDFs is a separate, more recent setting. Check the docs for the Spark version your Glue or EMR release actually ships — those are usually behind the current release (lesson 5).
⚠️ The fallback setting is a trap in disguise. Silent fallback to the non-Arrow path means a job that quietly gets much slower without failing. If you depend on Arrow for your performance budget, you want to know when it falls back, not to sail past it.
Supported types are not universal:
"Currently, all Spark SQL data types are supported by Arrow-based conversion except
ArrayTypeofTimestampType.MapTypeandArrayTypeof nestedStructTypeare only supported when using PyArrow 2.0.0 and above."
And the documented minimums: "For usage with pyspark.sql, the minimum supported versions of Pandas is 2.2.0 and PyArrow is 18.0.0."
A pandas UDF is vectorised: it receives a batch as a pandas.Series rather than a value per row.
The four documented signatures:
| Type | Signature | Documented constraint |
|---|---|---|
| Series to Series | pandas.Series, … -> pandas.Series |
"The output of the function should always be of the same length as the input." |
| Iterator of Series | Iterator[pandas.Series] -> Iterator[pandas.Series] |
"The length of the entire output from the function should be the same length of the entire input" |
| Iterator of multiple Series | Iterator[Tuple[pandas.Series, ...]] -> Iterator[pandas.Series] |
As above, over a tuple of Series |
| Series to Scalar | pandas.Series, … -> Any |
"The return type should be a primitive data type" |
⚠️ The Series-to-Scalar constraint is significant. The docs state it "does not support partial aggregation and all data for a group or window will be loaded into memory. Also, only unbounded window is supported."
All data for a group loaded into memory — so a skewed group will OOM the executor. If one key has 50 million rows, that group must fit in memory. Lesson 4 is about exactly that.
Iterator variants are the underused ones. They let you initialise something expensive once per batch-iterator rather than per batch — loading a model, opening a connection — which is often the difference between a viable and an unviable design.
Distinct from pandas UDFs, and worth knowing by name:
| API | What it does | Documented constraint |
|---|---|---|
DataFrame.groupby().applyInPandas() |
"maps each group to each pandas.DataFrame" — split-apply-combine |
"all data for a group will be loaded into memory before the function is applied" |
DataFrame.mapInPandas() |
"maps an iterator of pandas.DataFrames to another iterator of pandas.DataFrames" |
"can return the output of arbitrary length" |
DataFrame.groupby().cogroup().applyInPandas() |
Cogroups two DataFrames by a common key | "all data for a cogroup will be loaded into memory" |
mapInPandas is the one to remember — it's the only one here that can change the output row
count, and it streams rather than materialising a whole group. When you genuinely need Python over
large data, this is usually the right shape.
⚠️ Note that two of the three explicitly load an entire group into memory. That constraint is documented, not incidental, and it makes group size a correctness-of-design question rather than a tuning detail.
| Situation | Use |
|---|---|
Anything expressible in pyspark.sql.functions |
Built-in. Always. |
| Row-wise maths / string work over a column | pandas UDF, Series to Series |
| Expensive per-batch setup (model, connection) | pandas UDF, Iterator variant |
| Custom aggregation per group, groups fit in memory | applyInPandas |
| Python over large data, output size varies | mapInPandas |
| Genuinely scalar, one-off, tiny data | Plain Python UDF is acceptable |
Never argue about UDF cost — measure it. The comparison that settles it:
import time
def timed(label, fn):
t = time.time(); fn(); print(label, round(time.time() - t, 1), "s")
timed("builtin", lambda: df.withColumn("y", upper(col("x"))).write.mode("overwrite").parquet(a))
timed("pandas_udf", lambda: df.withColumn("y", pandas_upper(col("x"))).write.mode("overwrite").parquet(b))
timed("python_udf", lambda: df.withColumn("y", python_upper(col("x"))).write.mode("overwrite").parquet(c))
Write to storage rather than count() so you're measuring the real path, and use the same data each
time. I'm deliberately not quoting a speedup multiplier here — published figures vary hugely with
data shape, types, and version, and a number I can't reproduce for your workload is worse than no
number. Run it on your data.
filter() especially bad?spark.sql.execution.arrow.maxRecordsPerBatch control, and what's the documented default?mapInPandas — documented as able to "return the output of arbitrary length". applyInPandas and
the cogrouped variant both load a whole group into memory.pyspark.sql.functions first. Ask the room to name ten built-ins — most
struggle past six, which is the point.explain() with a UDF in the filter and with an
isin, and point at where PushedFilters disappears.explain().