DE Learning

Spark Core · Module 2 · Deep dive

Arrow and vectorised execution

Why pandas UDFs are so much faster than plain Python UDFs, what Apache Arrow actually does, and the three different things people mean by "vectorised" in Spark.

Row layout vs columnar layout

The same three-row table can sit in memory two ways. Row layout keeps each record together, which suits reading or writing whole records. Columnar layout keeps each column together, which suits analytics: most queries touch a few columns of many rows.

The same three rows stored record by record, and column by column Row layoutone record after another · Spark's internal rows, pickled Python rows Columnar layoutone column after another · Arrow, pandas, Parquet 1Gold1202Silver803Gold300123GoldSilverGold12080300 cust_idsegmentamount
Highlights the values each layout has to read to add up amount.

What Apache Arrow is

Apache Arrow is a standard columnar format for data in memory, defined the same way for every language. A batch of Arrow data (a record batch) is a set of column buffers plus a schema. Because Java, Python, C++ and others all understand the same layout, they can hand data to each other without converting it value by value, often without copying it at all.

Parquet is columnar on disk; Arrow is columnar in memory. pandas and NumPy are columnar in memory too, which is why converting Arrow to pandas is cheap.

Crossing the JVM ↔ Python boundary

Spark's engine runs in the JVM; your Python function runs in separate Python worker processes on each executor. Every Python UDF means moving data across that process boundary and back. How it's moved makes the difference.

Race: a plain Python UDF sends rows one at a time to the Python worker, a pandas UDF sends whole Arrow batches Plain Python UDF pickle → send → call f(row) → send back, for every row Executor JVMTungsten rows Python workerf(row) pandas UDF (Arrow) one Arrow batch (~10,000 rows; 250 here) → call f(series) once Executor JVMTungsten rows Python workerf(series)
1,000 rows. Timing is illustrative, not a benchmark.
0Python UDF: rows done
0Python UDF: function calls
–Python UDF: finished at
0pandas UDF: rows done
0pandas UDF: function calls
–pandas UDF: finished at
Plain Python UDFpandas UDF (Arrow)
Unit of transferOne row, pickledOne Arrow record batch, ~10,000 rows by default
ConversionsEvery value converted to a Python object and backColumn buffers handed over; pandas wraps them
Your function is calledOnce per row, through the Python interpreterOnce per batch, with whole columns as pandas Series
Plan node in explain()BatchEvalPythonArrowEvalPython

Batches of rows are buffered in the Python worker's memory, which counts against the executor's overhead, not its heap. If pandas UDF stages get containers killed, lower the batch size or raise overhead (see Module 1).

What "vectorised" means

Vectorised code applies one operation to a whole array of values at once, instead of looping over values one at a time in an interpreted language. In pandas and NumPy, s * 1.05 runs a single loop in compiled C over the whole column, often using CPU instructions that process several numbers per step (SIMD). A plain Python function pays the interpreter's overhead on every single value.

import pandas as pd
from pyspark.sql import functions as F
from pyspark.sql.functions import udf, pandas_udf

@udf("double")
def tax_row(amount):                       # called once per row, in the Python interpreter
    return amount * 1.05

@pandas_udf("double")
def tax_vec(amount: pd.Series) -> pd.Series:   # called once per batch of ~10,000 rows
    return amount * 1.05                   # one compiled loop over the whole array

df.withColumn("t", tax_row("amount"))      # slowest: pickle + interpreter per row
df.withColumn("t", tax_vec("amount"))      # much faster: Arrow + vectorised NumPy
df.withColumn("t", F.col("amount") * 1.05) # fastest: no Python at all
A pandas UDF isn't automatically vectorised
If your pandas UDF does s.apply(lambda x: …) or loops over rows in Python, you get Arrow's cheaper transfer but still pay the interpreter per value. The big win needs whole-column pandas or NumPy operations inside the function.

Where Spark uses Arrow

APIFunction shapeUse it for
df.toPandas() / spark.createDataFrame(pdf)Whole DataFrame, via the driverMoving small results between Spark and pandas
pandas UDF, Series → Seriesdef f(s: pd.Series) -> pd.SeriesColumn transforms in select / withColumn
pandas UDF, Iterator of Seriesdef f(it: Iterator[pd.Series]) -> Iterator[pd.Series]Expensive setup once per partition, e.g. loading a model
pandas UDF, Series → scalardef f(s: pd.Series) -> floatCustom aggregations in groupBy().agg(); each group in memory
mapInPandasIterator[pd.DataFrame] → Iterator[pd.DataFrame]Whole-row logic over batches, no grouping
applyInPandaspd.DataFrame → pd.DataFrame, per groupPer-group logic; whole group in one worker
Arrow-optimised Python UDF@udf(…, useArrow=True), row-wise functionKeeping existing row-wise code but speeding up the transfer (Spark 3.5+)

pandas UDFs, mapInPandas and applyInPandas always use Arrow. The arrow.pyspark.enabled setting controls only toPandas and createDataFrame from pandas.

Configs worth knowing

ConfigDefaultEffect
spark.sql.execution.arrow.pyspark.enabledfalse*Use Arrow for toPandas and createDataFrame(pdf)
spark.sql.execution.arrow.pyspark.fallback.enabledtrueSilently fall back to the slow path if Arrow fails (see production)
spark.sql.execution.arrow.maxRecordsPerBatch10000Rows per Arrow batch for pandas UDFs; lower it if Python workers run out of memory
spark.sql.execution.pythonUDF.arrow.enabledfalseUse Arrow for all regular Python UDFs (Spark 3.5+)
spark.sql.parquet.enableVectorizedReadertrueRead Parquet in columnar batches (see below)

* Off in open-source Spark; enabled by default on Databricks.

Three things called "vectorised" in Spark

TermWhat runs on batches of columnsWhere you'll meet it
pandas UDFs / ArrowYour Python function, with NumPy and pandas doing the loopsPySpark code
Vectorised Parquet / ORC readerDecoding files into column batches (4,096 rows by default) instead of row by rowEvery scan stage; shows as ColumnarToRow in explain() when batches are turned into rows for the next operators
PhotonThe whole query engine, written in C++, processing column batches with SIMDDatabricks; plan nodes named Photon…

For contrast, open-source Spark's Tungsten whole-stage codegen fuses the operators of a stage into one compiled Java function that processes one row at a time in a tight loop. It's fast because it removes per-operator overhead, but it's row-oriented, not vectorised. That's the main design difference Photon changes.

Say it in an interview

Why pandas UDFs beat Python UDFs
"A plain Python UDF pickles every row, sends it to a Python worker and calls the function once per row in the interpreter. A pandas UDF ships Arrow record batches of about ten thousand rows, so there's no per-row conversion, and the function runs once per batch on whole pandas Series, which NumPy processes in compiled, vectorised loops."
What Arrow is
"Apache Arrow is a language-neutral columnar in-memory format. Because the JVM and Python agree on the layout, Spark can hand column buffers to pandas without converting each value. Spark uses it for toPandas, createDataFrame from pandas, and all pandas UDFs."
"Vectorised" in Spark
"It can mean pandas UDFs running NumPy over Arrow batches, the vectorised Parquet reader decoding column batches, or Photon, Databricks' C++ engine that processes whole column batches. Open-source Spark's whole-stage codegen is compiled but processes one row at a time."

Quick check

Does setting arrow.pyspark.enabled=false make pandas UDFs stop using Arrow?
No. pandas UDFs, mapInPandas and applyInPandas always use Arrow. That setting only affects toPandas() and createDataFrame from a pandas DataFrame.
Your pandas UDF calls s.apply(my_func). Is it vectorised?
Only the transfer is. apply calls a Python function per value, so you still pay interpreter overhead per row. Rewrite with whole-Series operations (arithmetic, np.where, .str methods) to get the real speed-up.
A pandas UDF stage keeps getting containers killed. Which Arrow setting can help?
spark.sql.execution.arrow.maxRecordsPerBatch: smaller batches mean less Python-worker memory per task. Raising executor memory overhead also helps.
Why is Arrow → pandas conversion cheap but pickle → pandas expensive?
Arrow and pandas are both columnar, so pandas can wrap Arrow's column buffers, often without copying. Pickled rows must be decoded one Python object at a time and reassembled into columns.

Mark this topic