BookinglyTech News
Software

Tres patrones de Polars que separan un script rápido de uno lento

El motor en Rust y el optimizador explican la velocidad de Polars, pero la versión lenta de un script se parece demasiado a la rápida. Tres sitios donde se cruza esa frontera sin darse cuenta.

3 min de lecturaKDnuggets0 vistas

Polars es rápido por dos motivos: su motor de expresiones, escrito en Rust y repartido entre todos los núcleos disponibles, y un optimizador que reescribe la consulta antes de ejecutarla. Casi todo script lento falla en uno de esos dos puntos, y el problema es que la versión rápida y la lenta se parecen mucho sobre la página. Todo lo que sigue está comprobado contra Polars 1.44.2, con un mes de viajes de taxi amarillo de Nueva York en Parquet como conejillo de indias.

Escanear en lugar de leer

pl.read_parquet carga el archivo entero en memoria y después te deja filtrar. pl.scan_parquet devuelve un LazyFrame: solo registra lo que has pedido, no lo ejecuta. Ese hueco es donde trabaja el optimizador, empujando el filtro y la lista de columnas hasta el propio escaneo, de modo que el recorte ocurre mientras se lee el archivo. Las filas descartadas nunca se decodifican.

q = (
    pl.scan_parquet("yellow_tripdata_2026-01.parquet")
    .filter(pl.col("fare_amount") > 50)
    .select("PULocationID", "tip_amount")
    .group_by("PULocationID")
    .agg(pl.col("tip_amount").mean())
)
print(q.explain())  # imprime el plan, no los datos
df = q.collect()    # nada se materializa hasta aquí

explain() saca el plan que ha construido el optimizador, con el predicado ya empujado y la lista de columnas recortada. El hábito que conviene romper es llamar a collect() a mitad de la cadena por nerviosismo: cada collect() es un muro que el optimizador no ve atravesar.

Valores por grupo sin la ida y vuelta

Para devolver un agregado de grupo a cada fila se suele montar un group_by con su agg y un join de vuelta al dataframe original. Son dos pasadas completas, un intermedio materializado y una clave de join con la que pelearse. over() hace el mismo trabajo dentro de una sola expresión, en una pasada y conservando el orden de las filas. Su estrategia por defecto, group_to_rows, mapea cada agregado a las filas de las que salió. Además admite order_by, que convierte un total acumulado o un lag por grupo en una línea en vez de un sort seguido de un join. explode solo cuando quieras cambiar la forma del frame; el valor por defecto acierta casi siempre.

Sacar Python del bucle

map_elements entrega cada valor de una columna a una función de Python, uno a uno, y la documentación avisa sin rodeos de que es mucho más lento que la API de expresiones nativa. Polars incluso lanza un PolarsInefficientMapWarning cuando detecta un map que cree poder sustituir. La mayoría de los usos son condicionales, y eso lo resuelve when / then / otherwise sin salir del motor. Un detalle que muerde: Polars calcula todas las ramas de la cadena en paralelo y filtra después, así que cada rama tiene que ser válida por sí sola.

Los tres trucos son en realidad el mismo: mantener el trabajo dentro del motor en lugar de devolvérselo a Python o a la memoria. Cuando un script tarda más de lo que debería, la primera pregunta no es qué flag ajustar, sino qué frontera se ha cruzado. Es un cambio de criterio, no una optimización puntual, y se nota sobre todo en pipelines que encadenan varias operaciones sobre Parquet.