Per-row UDF versus vectorized UDF
Measure your machineNeeds Python with pandas, NumPy, PyArrow
Problem
Calling a function once per row pays the call's fixed cost (interpreter dispatch, boxing, crossing from the engine into Python) on every row; a vectorized function pays it once per block, so its cost per row falls with block size until the fixed part is negligible. For a cheap model, the per-call cost dominates, and the reading and writing around the model often cost more than the model itself.
Score 5 million rows with a cheap function x * 0.3 + 2.0. Compare (a) a row-at-a-time Python function (pandas apply), (b) a vectorized function over blocks of 10,000 rows, (c) the same over blocks of 100 rows and of 1,000,000 rows. Also time reading the data from Parquet and writing the result.
The question. How much slower is the per-row function, how does block size matter, and how does the model call compare with I/O?
Predict
Expected result: (a) is 50 to 500 times slower than (b) because of the per-call cost. Block size 100 is slower than 10,000, and 1,000,000 gives little extra gain. The read and write time is likely larger than the model call time.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
per-row-udf-versus-vectorized-udf.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "pandas", "pyarrow"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Per-row UDF against vectorized UDF. Scores 5 million rows with x * 0.3 + 2.0: (a) a Python function called once per row (pandas apply), (b) a vectorized function over blocks of 100, 10,000 and 1,000,000 rows. Also times reading the input from Parquet and writing the result without the function. """ import argparse, json, os, tempfile, time import numpy as np, pandas as pd p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=5_000_000, help="Rows.") p.add_argument("--blocks", default="100,10000,1000000", help="Block sizes for the vectorized function.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() d = tempfile.mkdtemp(); src = os.path.join(d, "in.parquet"); out = os.path.join(d, "out.parquet") pd.DataFrame({"x": np.random.default_rng(0).random(args.rows)}).to_parquet(src) t = time.perf_counter(); df = pd.read_parquet(src); read_s = time.perf_counter() - t t = time.perf_counter(); df.assign(score=df.x).to_parquet(out); write_s = time.perf_counter() - t def per_row(v): return v * 0.3 + 2.0 def vectorized(a): return a * 0.3 + 2.0 rows = [] t = time.perf_counter(); r1 = df.x.apply(per_row); rows.append({"method": "(a) per-row Python function", "block": 1, "seconds": time.perf_counter() - t}) x = df.x.to_numpy() for b in map(int, args.blocks.split(",")): t = time.perf_counter(); r2 = np.concatenate([vectorized(x[i:i + b]) for i in range(0, len(x), b)]) rows.append({"method": f"(b) vectorized, blocks of {b:,}", "block": b, "seconds": time.perf_counter() - t}) assert np.allclose(r1.to_numpy(), r2) for f in (src, out): os.remove(f) base = next(r["seconds"] for r in rows if r["block"] == 10_000) for r in rows: r["vs_10k_blocks"] = r["seconds"] / base summary = {"read_s": read_s, "write_s": write_s, "per_row_over_10k": rows[0]["seconds"] / base} for r in rows: print(f"{r['method']:<34} {r['seconds']:8.3f} s ({r['vs_10k_blocks']:7.1f}x blocks of 10,000)") print(f"reading the input {read_s:.3f} s, writing the result {write_s:.3f} s") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"methods": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run per-row-udf-versus-vectorized-udf.py uv run per-row-udf-versus-vectorized-udf.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows5,000,000 | Rows. |
| --blocks100, 10,000, 1,000,000 | Block sizes for the vectorized function. |
Our run
5 million rows
| Method | Time | vs blocks of 10,000 |
|---|---|---|
| (a) per-row Python function | 425 ms | 47.8× |
| (b) vectorized, blocks of 100 | 62.3 ms | 7.01× |
| (b) vectorized, blocks of 10,000 | 8.89 ms | 1× |
| (b) vectorized, blocks of 1,000,000 | 5.84 ms | 0.657× |
(a) per-row Python function 0.425 s ( 47.8x blocks of 10,000)
(b) vectorized, blocks of 100 0.062 s ( 7.0x blocks of 10,000)
(b) vectorized, blocks of 10,000 0.009 s ( 1.0x blocks of 10,000)
(b) vectorized, blocks of 1,000,000 0.006 s ( 0.7x blocks of 10,000)
reading the input 1.763 s, writing the result 0.191 sWhat we found
The per-row function took 425 ms, 47.8× the vectorized version over blocks of 10,000 (8.89 ms). Blocks of 100 were 7.01× slower than blocks of 10,000, and blocks of a million barely faster. Reading the input took 1.76 s and writing the result 191 ms, more than the vectorized model call itself.
Read it
Inference in a pipeline is a batch-size problem first: amortize the per-call cost over thousands of rows. After that, the model is rarely the expensive part; the bytes around it are.