Python UDFs vs native code and plugins

Polars · Expert · 15 min

One careless Python lambda can make a Polars query 10 to 100 times slower; experts know when to avoid them and what to use instead.

Polars runs expressions in Rust on Arrow buffers across many threads. Python code breaks that model:

  • `map_elements(f, return_dtype=...)` calls a Python function once per value. It holds the GIL, runs single-threaded, converts each value to a Python object, and is opaque to the optimizer. It is the last resort. Polars even warns when it recognizes a lambda it could translate into a native expression.
  • `map_batches(f)` calls the function once per whole Series (or batch), so a NumPy-vectorized function stays fast. NumPy ufuncs can be applied directly: np.log(pl.col("x")) dispatches without leaving the engine. Batch-wise functions must not depend on how data is chunked unless you account for it.
  • `pl.struct(...).map_batches` passes several columns at once.

Before writing a UDF, look for a native path: the str, list, dt and struct namespaces, pl.when, list.eval, fold/reduce across columns, or replace and replace_strict for mappings.

When you genuinely need custom logic at native speed, write an expression plugin: a Rust function compiled with pyo3-polars and registered with polars.plugins.register_plugin_function. Plugins run in parallel without the GIL and appear in query plans like built-ins. Always set return_dtype on Python UDFs so the lazy schema is known.

Example

import time
import numpy as np
import polars as pl

df = pl.DataFrame({"x": np.arange(1, 1_000_001, dtype=np.float64)})

t = time.perf_counter()
slow = df.select(pl.col("x").map_elements(lambda v: v * 2 + 1,
                                          return_dtype=pl.Float64))
t_slow = time.perf_counter() - t

t = time.perf_counter()
fast = df.select(pl.col("x") * 2 + 1)          # native expression
t_fast = time.perf_counter() - t

batch = df.select(pl.col("x").map_batches(np.log, return_dtype=pl.Float64))
print(slow.equals(fast), round(batch["x"][0], 3))   # True 0.0
print(f"native ~{t_slow / t_fast:.0f}x faster")     # often 10-100x, machine-dependent

Remember

  • map_elements = per-value Python: slow, single-threaded, opaque
  • map_batches with vectorized NumPy code is acceptable
  • Rust expression plugins give custom logic at native speed

Watch out for

  • Using map_elements for logic that a namespace method, when/then or replace already covers.

Check yourself

  1. Why does a map_elements lambda block predicate pushdown?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.pola.rs/user-guide/expressions/user-defined-python-functions/