Polars

15 min lectura José Miguel

Si trabajan con datos en Python, es muy probable que en algún momento se hayan encontrado con un problema familiar: cargan un archivo CSV enorme, aplican varias transformaciones, y de repente su script consume toda la memoria disponible o tarda una eternidad en ejecutarse. Esto es algo que le sucede a prácticamente todos los que vienen del mundo de Pandas, y es precisamente el tipo de problema que Polars resuelve de una forma muy elegante gracias a un concepto llamado lazy evaluation.

La evaluación perezosa no es una idea nueva en el mundo de la computación — sistemas como Apache Spark la utilizan desde hace años — pero Polars la implementa de una manera que resulta accesible incluso para quienes están dando sus primeros pasos con esta librería. En lugar de ejecutar cada operación en el momento en que nosotros la escribimos, Polars construye internamente un plan de ejecución que optimiza antes de procesar un solo dato. El resultado es un consumo de memoria drásticamente menor y tiempos de ejecución que pueden ser varias veces más rápidos.

En este artículo, vamos a ver exactamente cómo funciona la lazy evaluation en Polars, cuál es la diferencia con el modo eager que usa Pandas, y cómo aplicarla en la práctica con ejemplos de código que ustedes pueden ejecutar directamente. Al final, van a tener claro cuándo conviene usarla y cómo sacarle el máximo provecho en sus proyectos de datos.

¿Qué es la Lazy Evaluation en Polars?

Para entender la lazy evaluation, primero pensemos en cómo funciona el modo eager (ansioso), que es el comportamiento por defecto en Pandas. Cuando nosotros escribimos una instrucción como df.filter(...) en Pandas, esa operación se ejecuta inmediatamente. El resultado se materializa en memoria en ese preciso instante, sin importar si después vamos a aplicar cinco transformaciones más sobre ese mismo resultado.

En cambio, cuando trabajamos con lazy evaluation en Polars, cada operación que escribimos no se ejecuta de inmediato. Lo que Polars hace es registrar esa operación en un grafo interno — una especie de plan de trabajo — y espera hasta que nosotros explícitamente le pidamos el resultado final con el método .collect(). Solo en ese momento Polars analiza todas las operaciones que le pedimos, las reorganiza para que sean lo más eficientes posible, y las ejecuta en un solo paso optimizado.

Vamos a verlo con una analogía sencilla. Imaginen que están en un supermercado con una lista de compras. El modo eager sería como caminar al pasillo de frutas, traer las manzanas al carrito, luego ir al pasillo de lácteos, traer la leche, y así sucesivamente haciendo un viaje por cada producto. El modo lazy sería como primero leer toda la lista, organizarla por pasillo, y luego recorrer el supermercado una sola vez recogiendo todo en orden. El resultado es el mismo, pero el recorrido es mucho más eficiente.

Eager vs Lazy: Diferencias clave

Para que quede claro de un vistazo, veamos las diferencias principales entre ambos modos de ejecución en Polars. Es importante mencionar que Polars soporta ambos modos — a diferencia de Pandas, que solo opera en modo eager.

CaracterísticaModo Eager (DataFrame)Modo Lazy (LazyFrame)
EjecuciónInmediata, operación por operaciónDiferida hasta llamar .collect()
OptimizaciónNo hay optimización entre operacionesEl query optimizer reorganiza y fusiona operaciones
Consumo de memoriaMaterializa resultados intermediosMinimiza datos intermedios en memoria
ParalelismoSí, dentro de cada operaciónSí, entre operaciones y dentro de ellas
Uso recomendadoExploración rápida, datasets pequeñosPipelines de producción, datasets grandes
Objeto principalpl.DataFramepl.LazyFrame

Fíjense en un detalle importante: el modo lazy no solo difiere la ejecución, sino que activa el query optimizer de Polars. Este optimizador es capaz de hacer cosas como eliminar columnas que nunca se usan en el resultado final, fusionar múltiples filtros en uno solo, o reorganizar el orden de las operaciones para minimizar la cantidad de datos que se procesan en cada paso. Esto es algo que simplemente no es posible en modo eager, porque cada operación se ejecuta de forma aislada sin conocimiento de lo que viene después.

Cómo crear un LazyFrame en Polars

Existen dos formas principales de trabajar en modo lazy con Polars. La primera es convertir un DataFrame existente a un LazyFrame usando el método .lazy(). La segunda — y la más eficiente — es leer los datos directamente en modo lazy desde el origen.

Veamos ambas opciones:

import polars as pl

# Opción 1: Convertir un DataFrame existente a LazyFrame
df = pl.DataFrame({
    "producto": ["Laptop", "Mouse", "Teclado", "Monitor", "Webcam"],
    "categoria": ["Electrónica", "Accesorios", "Accesorios", "Electrónica", "Accesorios"],
    "precio": [1200.0, 25.0, 45.0, 350.0, 80.0],
    "unidades_vendidas": [150, 2000, 1800, 300, 950]
})

lf = df.lazy()
print(type(lf))
# <class 'polars.lazyframe.frame.LazyFrame'>

Como pueden ver, al llamar .lazy() sobre un DataFrame, obtenemos un objeto de tipo LazyFrame. Este objeto tiene prácticamente los mismos métodos que un DataFrame — filter, select, group_by, with_columns — pero ninguno de ellos ejecuta nada. Solo registran la operación.

Ahora veamos la segunda opción, que es la más recomendada cuando trabajamos con archivos:

# Opción 2: Leer directamente en modo lazy (recomendado)
lf = pl.scan_csv("ventas_2024.csv")

# También funciona con Parquet, que es aún más eficiente
lf = pl.scan_parquet("ventas_2024.parquet")

# Y con otros formatos
lf = pl.scan_ndjson("eventos.ndjson")
lf = pl.scan_ipc("datos.arrow")

La diferencia entre read_csv y scan_csv es fundamental. Cuando usamos read_csv, Polars carga todo el archivo en memoria de inmediato. Cuando usamos scan_csv, Polars solo registra la ubicación del archivo y espera a que definamos todas nuestras operaciones antes de leer algo. Esto significa que si después filtramos solo las filas de una categoría específica y seleccionamos tres columnas de veinte, Polars puede leer únicamente lo que necesita — especialmente con formatos columnares como Parquet.

Operaciones comunes con LazyFrames

Vamos a construir un pipeline de transformación completo usando lazy evaluation. Lo que nosotros vamos a hacer es encadenar varias operaciones sobre un LazyFrame y ejecutarlas todas de una sola vez al final.

import polars as pl

# Simulamos un dataset de ventas
lf = pl.scan_csv("ventas_2024.csv")

# Construimos nuestro pipeline de transformaciones
resultado = (
    lf
    # Filtrar solo ventas completadas
    .filter(pl.col("estado") == "completada")
    # Crear una columna de ingreso total
    .with_columns(
        (pl.col("precio") * pl.col("cantidad")).alias("ingreso_total")
    )
    # Agrupar por categoría y mes
    .group_by("categoria", "mes")
    .agg([
        pl.col("ingreso_total").sum().alias("ingresos"),
        pl.col("cantidad").sum().alias("unidades"),
        pl.col("id_venta").count().alias("num_transacciones")
    ])
    # Ordenar por ingresos de mayor a menor
    .sort("ingresos", descending=True)
)

# Hasta aquí, NO se ha ejecutado nada
# El resultado es un LazyFrame con el plan registrado
print(type(resultado))
# <class 'polars.lazyframe.frame.LazyFrame'>

# Ahora sí ejecutamos todo el pipeline
df_resultado = resultado.collect()
print(df_resultado)

Fíjense en cómo definimos cinco operaciones encadenadas — filtro, nueva columna, agrupación, agregación y ordenamiento — y ninguna se ejecutó hasta que llamamos .collect(). En ese momento, Polars tomó todo el plan, lo optimizó internamente, y ejecutó el pipeline completo de la manera más eficiente posible.

Si en algún punto necesitamos ver una muestra rápida de los datos sin ejecutar todo el pipeline, podemos usar .fetch() en lugar de .collect():

# Obtener solo las primeras n filas para inspección rápida
muestra = resultado.fetch(n_rows=5)
print(muestra)

Esto es muy útil durante el desarrollo, porque nos permite verificar que nuestro pipeline funciona correctamente sin tener que procesar el dataset completo.

El query plan: Cómo Polars optimiza nuestras consultas

Una de las ventajas más poderosas de la lazy evaluation en Polars es que podemos inspeccionar el plan de ejecución antes de ejecutarlo. Esto nos permite entender exactamente qué va a hacer Polars con nuestros datos y verificar que las optimizaciones se estén aplicando correctamente.

import polars as pl

lf = pl.scan_csv("ventas_2024.csv")

pipeline = (
    lf
    .select("categoria", "precio", "cantidad", "estado")
    .filter(pl.col("estado") == "completada")
    .with_columns(
        (pl.col("precio") * pl.col("cantidad")).alias("ingreso")
    )
)

# Ver el plan de ejecución sin optimizar
print(pipeline.explain(optimized=False))

# Ver el plan de ejecución optimizado
print(pipeline.explain(optimized=True))

El resultado que se muestra a continuación es una representación del plan de ejecución. Lo interesante es comparar el plan sin optimizar contra el optimizado. Veamos qué tipo de optimizaciones aplica Polars automáticamente:

Projection pushdown: Si nosotros seleccionamos solo 4 columnas de un archivo que tiene 20, Polars no va a leer las otras 16. Esta optimización «empuja» la selección de columnas hasta el punto más temprano posible del pipeline, reduciendo drásticamente la cantidad de datos que se cargan en memoria.

Predicate pushdown: De forma similar, si filtramos filas con una condición, Polars mueve ese filtro lo más cerca posible de la lectura del archivo. Esto significa que las filas que no cumplen la condición nunca llegan a procesarse en los pasos siguientes.

Simplificación de expresiones: Si tenemos operaciones redundantes o que se pueden combinar, el optimizador las fusiona. Por ejemplo, dos filtros consecutivos pueden convertirse en un solo filtro con una condición AND.

Common subexpression elimination: Si la misma expresión aparece en múltiples lugares, Polars la calcula una sola vez y reutiliza el resultado.

Estas optimizaciones suceden de forma transparente. Nosotros no tenemos que hacer nada especial — simplemente por usar el modo lazy, Polars las aplica automáticamente.

Ejemplo práctico: Procesando un dataset grande paso a paso

Vamos a poner todo en práctica con un ejemplo más realista. Supongamos que tenemos un archivo Parquet con datos de transacciones de un e-commerce que pesa varios gigabytes. Lo que nosotros queremos es obtener un resumen de los 10 productos más vendidos por región, considerando solo las ventas del último trimestre.

import polars as pl
from datetime import date

# Leemos en modo lazy — no se carga nada en memoria todavía
lf = pl.scan_parquet("transacciones_ecommerce.parquet")

# Definimos todo el pipeline
top_productos_por_region = (
    lf
    # Filtrar transacciones del último trimestre
    .filter(
        pl.col("fecha_venta") >= date(2024, 10, 1)
    )
    # Filtrar solo ventas exitosas
    .filter(
        pl.col("estado_transaccion") == "exitosa"
    )
    # Calcular el ingreso por transacción
    .with_columns(
        (pl.col("precio_unitario") * pl.col("cantidad")).alias("ingreso")
    )
    # Agrupar por región y producto
    .group_by("region", "nombre_producto")
    .agg([
        pl.col("ingreso").sum().alias("ingreso_total"),
        pl.col("cantidad").sum().alias("unidades_totales"),
        pl.col("id_transaccion").count().alias("num_ventas")
    ])
    # Calcular el ticket promedio
    .with_columns(
        (pl.col("ingreso_total") / pl.col("num_ventas")).alias("ticket_promedio")
    )
    # Ordenar por ingreso total dentro de cada región
    .sort("ingreso_total", descending=True)
)

# Verificamos el plan antes de ejecutar
print(top_productos_por_region.explain())

# Ejecutamos y obtenemos el resultado
df_resultado = top_productos_por_region.collect()
print(df_resultado.head(20))

Como ustedes pueden ver, definimos un pipeline de seis operaciones encadenadas que incluye filtros, columnas calculadas, agrupaciones y ordenamiento. Si hubiéramos hecho esto en modo eager — o en Pandas — cada paso habría generado un DataFrame intermedio en memoria. Con un dataset de varios gigabytes, eso podría significar la diferencia entre que el script funcione o que termine con un error de memoria.

Con lazy evaluation, Polars lee del archivo Parquet solo las columnas que necesitamos (projection pushdown), solo las filas que pasan los filtros (predicate pushdown), y ejecuta todo el pipeline optimizado de una sola vez. Si quieren profundizar más en este tipo de técnicas y trabajar con proyectos de datos reales usando Polars, en mi curso Aprende a manipular datos con Polars y Python en Udemy lo cubrimos con ejercicios prácticos que van desde lo básico hasta pipelines de producción.

Streaming: Cuando los datos no caben en memoria

Hay un caso aún más extremo: ¿qué pasa cuando el dataset es tan grande que ni siquiera el resultado final cabe en memoria? Para esto, Polars ofrece el modo streaming, que procesa los datos en lotes (batches) sin necesidad de cargar todo de una vez.

import polars as pl

# Procesamos un archivo enorme en modo streaming
resultado = (
    pl.scan_parquet("logs_servidor_50gb.parquet")
    .filter(pl.col("status_code") >= 500)
    .group_by("endpoint")
    .agg([
        pl.col("status_code").count().alias("total_errores"),
        pl.col("response_time_ms").mean().alias("tiempo_respuesta_promedio")
    ])
    .sort("total_errores", descending=True)
    .collect(streaming=True)  # Activamos el modo streaming
)

print(resultado)

La única diferencia en nuestro código es pasar streaming=True al método .collect(). Internamente, Polars divide el procesamiento en lotes que caben en memoria y los va procesando de forma secuencial. Esto permite trabajar con datasets que superan la RAM disponible, algo que en Pandas es prácticamente imposible sin recurrir a herramientas externas como Dask o Spark.

Es importante mencionar que no todas las operaciones son compatibles con el modo streaming. Las operaciones que requieren ver todos los datos de una vez — como ciertos tipos de joins o la ordenación global del dataset completo — pueden no ser elegibles. Polars nos avisará si una operación específica no se puede ejecutar en modo streaming.

Cuándo usar Lazy Evaluation y cuándo no

La lazy evaluation no es siempre la mejor opción. Como con cualquier herramienta, el contexto importa. Veamos cuándo conviene cada modo:

Usen lazy evaluation cuando: estén construyendo pipelines de transformación con múltiples pasos encadenados, cuando trabajen con archivos grandes donde el projection pushdown y el predicate pushdown marcan diferencia real, cuando necesiten procesamiento streaming para datos que exceden la memoria, o cuando estén armando flujos de datos para producción donde la eficiencia importa.

Usen modo eager cuando: estén haciendo exploración interactiva rápida en un notebook y necesiten ver resultados inmediatos, cuando el dataset sea pequeño (menos de unos cientos de miles de filas) y la diferencia de rendimiento sea irrelevante, o cuando necesiten realizar operaciones que dependen de los datos concretos para decidir el siguiente paso — por ejemplo, inspeccionar valores únicos antes de decidir cómo filtrar.

En la práctica, un patrón muy común es comenzar en modo eager durante la fase de exploración y análisis inicial, y después convertir el pipeline a modo lazy cuando ya tenemos clara la lógica de transformación y queremos optimizar el rendimiento. La transición es sencilla porque la API es prácticamente idéntica en ambos modos.

Errores comunes al trabajar con LazyFrames

Antes de cerrar, quiero compartir algunos errores que nosotros vemos con frecuencia cuando se empieza a usar lazy evaluation:

Llamar .collect() demasiado pronto. Si llamamos .collect() después de cada operación, estamos básicamente usando modo eager con pasos extra. La ventaja de lazy evaluation está en acumular todas las operaciones y ejecutarlas juntas al final. Si nuestro código tiene múltiples .collect() intercalados, probablemente estemos perdiendo las optimizaciones.

# MAL: collect() después de cada paso
df = pl.scan_csv("datos.csv").filter(...).collect()  # primer collect innecesario
df = df.lazy().with_columns(...).collect()            # segundo collect innecesario
df = df.lazy().group_by(...).agg(...).collect()        # tercero

# BIEN: un solo collect() al final
df = (
    pl.scan_csv("datos.csv")
    .filter(...)
    .with_columns(...)
    .group_by(...)
    .agg(...)
    .collect()  # un único collect
)

Intentar imprimir un LazyFrame directamente. Un LazyFrame no contiene datos — contiene un plan. Si hacemos print(lf), no vamos a ver filas de datos sino una descripción del esquema. Para ver los datos, necesitamos .collect() o .fetch().

No verificar el plan de ejecución. El método .explain() es una herramienta muy valiosa que pocos aprovechan. Revisarlo nos permite confirmar que las optimizaciones como el pushdown de predicados y proyecciones se están aplicando correctamente, y puede revelar ineficiencias que no son obvias en el código.

Conclusión

La lazy evaluation es una de las características que hacen de Polars una herramienta tan eficiente para el procesamiento de datos. Al diferir la ejecución y dejar que el query optimizer haga su trabajo, podemos escribir pipelines de transformación que son más rápidos, consumen menos memoria y escalan mejor que el enfoque tradicional de ejecución inmediata. Lo más importante es que no necesitamos cambiar nuestra forma de pensar ni aprender una API completamente nueva — la sintaxis es casi idéntica al modo eager, solo cambia el momento en que los datos se procesan.

Los puntos clave que deben llevarse de este artículo son: usen scan_csv o scan_parquet en lugar de read_csv cuando trabajen con archivos grandes, encadenen todas sus operaciones antes de llamar .collect(), revisen el plan con .explain() para confirmar que las optimizaciones se aplican, y consideren el modo streaming=True cuando sus datos excedan la memoria disponible. ¿Ustedes ya están usando Polars en sus proyectos? Me encantaría saber si ya han probado la lazy evaluation y qué diferencias han notado en su flujo de trabajo.

José Miguel Moya Curbelo
José Miguel Moya Curbelo
Senior Data Engineer & Big Data Instructor

MSc Applied Mathematics · AWS Cloud Practitioner · SCRUM Master. Especializado en arquitecturas de datos de alto rendimiento con Apache Spark, Snowflake, Python y Scala.

Conectar en LinkedIn

Artículos Relacionados