A Spark benchmark's Arrow UDF row toggled a setting that never touches UDFs
A Spark benchmark's "Arrow" UDF row ran the pickle path, so its 48.9x against 48.5x tie says nothing about Arrow transport. The same benchmark's useArrow=True UDFs ran about 2x faster than the plain ones.
The Engineer · Build desk

Comparison of cost against the equivalent built-in function. At 50M rows, plain @udf ran at 48.5x and the @udf with Arrow turned on at 48.9x. At 100M rows, plain @udf ran at 69.2x and the Arrow-on @udf at 69.0x.
Cost as a multiple of the equivalent built-in function, per post author In x built-in cost
| Item | Value | Claim |
|---|---|---|
| Plain @udf, 50M rows | 48.5 x built-in cost | 2 |
| @udf, Arrow on, 50M rows | 48.9 x built-in cost | 2 |
| Plain @udf, 100M rows | 69.2 x built-in cost | 2 |
| @udf, Arrow on, 100M rows | 69 x built-in cost | 2 |
What happened
- The benchmark's Arrow case set spark.sql.execution.arrow.pyspark.enabled, which governs only DataFrame.toPandas() and createDataFrame(pandas_df).
- At 100M rows the same pair came out at 69.2x and 69.0x of the built-in function's time.
- The author puts a ceiling of roughly 8 to 10 million rows per second on row-at-a-time Python UDFs, near the rate CPython can be entered and exited.
Why it matters
- exposure A job or benchmark that sets only arrow.pyspark.enabled is still running pickled Python UDFs, so an Arrow comparison built on it compares one path with itself.
- contradiction The write-up said Arrow transport buys nothing for per-row UDFs, yet the benchmark's own useArrow=True rows show roughly a 2x gain.
- cost The 2x costs a config change. The next step, about 3x on arithmetic, means rewriting the function to take a pandas Series or Arrow array.
The failure is a naming problem. Every Python UDF call makes three separate choices: how data crosses from the JVM to the Python worker, which physical operator Spark plans (BatchEvalPython or ArrowEvalPython), and what the function receives once the data lands [8]. One config key sounds like it covers all three. According to the post's author, that key never touches Python UDFs [4], and the PySpark documentation says it controls pandas conversions [6].
That makes the original table an A/A test. The two rows were the same pickled UDF measured twice [7]. The gap was 0.8% at 50M rows and 0.3% at 100M [14]. The author's own phrase for it: "I had built an A/A test by accident" [13]. The post documents the author's write-up leaning on this pair as its key insight [3].
With the right switch, the benchmark's own numbers move. useArrow=True took arithmetic from 5.20s to 2.23s and the CDF workload from 6.18s to 2.94s [9]. That is 2.3x and 2.1x [15]. The original line, that Arrow buys nothing when the function runs one row at a time [3], is wrong as written.
The author's replacement thesis is that a UDF's cost depends far more on what the function receives than on whether Arrow carries the bytes [12]. The data fits that ordering. Vectorized UDFs cross into Python once per batch, and on arithmetic they ran about 3x faster again than useArrow=True [11]. Transport is worth about 2x. Execution model is worth roughly another 3x.
One figure in the post does not sit well with its own table. The author gives a ceiling of roughly 8 to 10 million rows per second for row-at-a-time UDFs, citing 50M rows in 5.2s and 100M in 10.1s [10]. Those work out to 9.6 and 9.9 million rows per second [16]. That fits the pickle UDF. If the 2.23s Arrow time comes from the same 50M-row run as the 5.20s baseline, useArrow=True ran at about 22 million rows per second [17]. Until the post gives the scale of the 2.23s figure, we would not quote the ceiling for Arrow-backed per-row UDFs.
Transfer is the other limit. These are one author's runs of x * 2 + 1, a string transform and a normal CDF [1]. The excerpt does not include the Spark version or the hardware. The 2x is serialization cost saved per row. In our view it carries to your job only if the function body is cheap enough that transport is a large share of the runtime. A UDF that spends its time in a model call will see less.
The check is short. Search job configs and benchmark harnesses for spark.sql.execution.arrow.pyspark.enabled. The settings that move a regular UDF are spark.sql.execution.pythonUDF.arrow.enabled for the session and useArrow=True per UDF [5]. Then look at the physical plan, since the two transports plan different operators [8].
What to watch
- Whether the author publishes the Spark version and hardware behind the runs.
- Whether the 2.23s useArrow=True figure comes from the 50M-row table, which decides if it breaks the stated 8 to 10 million rows per second ceiling.
- Whether reruns on UDFs with heavier function bodies show the same 2x from useArrow=True.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence58
- Adoption
- Insufficient
- Hype gap+8
- Incentives
- Insufficient
- Confidence62
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
The benchmark compared seven options across three workloads (trivial arithmetic, a string transform, and a normal CDF) at scales from 100K to 100M rows.
- [2]
A plain @udf ran at 48.5x the cost of the equivalent built-in function at 50M rows, and the same @udf 'with Arrow turned on' ran at 48.9x; at 100M rows the pair was 69.2x against 69.0x.
- [3]
The author's write-up called the pair the key insight: switching transport from pickle to Arrow buys nothing when the function still executes one row at a time.
- [4]
The benchmark's '@udf + Arrow' case set spark.sql.execution.arrow.pyspark.enabled, which only governs DataFrame.toPandas() and createDataFrame(pandas_df) and never touches Python UDFs.
- [5]
What moves a regular Python UDF onto Arrow is spark.sql.execution.pythonUDF.arrow.enabled (session-wide) or @udf(..., useArrow=True) (per UDF).
- [6]
The PySpark documentation is explicit that arrow.pyspark.enabled controls pandas conversions.
- [7]
The two nearly identical rows were the same pickled UDF measured twice, and the gap between them (under 1%) is just run-to-run variation.
- [8]
Every Python UDF call makes three independent choices: how data crosses from the JVM to the Python worker (transport), which physical operator Spark plans (BatchEvalPython or ArrowEvalPython), and what the function receives once the data arrives (the execution model), which determines how many times Python is entered.
- [9]
useArrow=True took arithmetic from 5.20s to 2.23s and the CDF workload from 6.18s to 2.94s.
- [10]
Across every workload and scale, the row-at-a-time UDFs topped out at roughly 8 to 10 million rows per second (50M rows in 5.2s; 100M in 10.1s), which the author describes as about the rate at which CPython can be entered and exited.
- [11]
The vectorized UDFs cross into Python once per batch and were about 3x faster than useArrow=True on arithmetic.
- [12]
The cost of a Python UDF is decided far more by what the function receives (a single value, a pandas Series, or an Arrow array) than by whether Arrow carries the bytes.
- [13]
"I had built an A/A test by accident"
- [14]
The gap between the 'plain' and 'Arrow config' rows was about 0.8% at 50M rows and about 0.3% at 100M rows.
- [15]
useArrow=True sped up arithmetic by about 2.3x and the CDF workload by about 2.1x.
- [16]
The cited ceiling data points work out to about 9.6 million rows per second at 50M rows and about 9.9 million at 100M rows.
- [17]
If the 2.23s useArrow=True arithmetic time is from the same 50M-row run as the 5.20s baseline, useArrow=True processed about 22 million rows per second, above the stated 8 to 10 million ceiling.
Get this ledger inside ChatGPT. Ask about any of our stories and the answer brings its figure, each claim’s verdict and the sources. Free, no account.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toStop Blaming Serialization for Slow Python UDFs
1 article · October 13, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.
Topics
- Apache Spark performanceFollow
- Python UDFsFollow
- Benchmarking MethodologyFollow