Skip to content
About
Main content

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 (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

Once the UDF is vectorized, which takes longer: moving the data in and out, or the model call?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    per-row-udf-versus-vectorized-udf.py46 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--rows5,000,000 Rows.
--blocks100, 10,000, 1,000,000 Block sizes for the vectorized function.

Our run

5 million rows

5 million rows
MethodTimevs blocks of 10,000
(a) per-row Python function425 ms47.8×
(b) vectorized, blocks of 10062.3 ms7.01×
(b) vectorized, blocks of 10,0008.89 ms1×
(b) vectorized, blocks of 1,000,0005.84 ms0.657×
Reference runApple M2 · 8 cores · 8 GB
Output · uv run per-row-udf-versus-vectorized-udf.py
(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 s
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · pandas 3.1.0 · pyarrow 26.0.0 · run 2026-10-25

What 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.