Polars: DataFrames de alto rendimiento en Python
Cómo usar Polars para operaciones rápidas de DataFrame con lazy evaluation, expression API, streaming e interop con pandas para datasets grandes.
Visión General
Polars es una librería de DataFrame escrita en Rust con bindings de Python. Guarda los datos como columnas Apache Arrow, paraleliza en todos los cores de la CPU y, en modo lazy, optimiza el query plan completo antes de leer una sola fila. La diferencia se ve en benchmarks, no solo en marketing: en el PDS-H oficial de Polars (mayo 2025, scale factor 10), el streaming engine terminó la suite en 3.89s mientras que pandas tardó 365.71s.
Yo uso Polars cuando un trabajo de pandas empieza a arrastrarse con archivos de varios GB pero montar Spark sería matar moscas a cañonazos. La expression API tarda una hora en hacer clic — escribes expresiones pl.col() en lugar de indexar columnas — y después la migración es bastante mecánica.
Cuándo Usarlo
- Datasets de 1GB a ~100GB que hacen que pandas haga swap o se arrastre. Caso típico: ETL de CSV/Parquet en una sola máquina.
- Pipelines con muchos group-by y joins donde pandas es el cuello de botella. El benchmark de arriba es exactamente esa carga.
- Reemplazar pandas dentro de un pipeline ETL existente sin pasar a Spark.
- Pipelines donde el optimizador importa, porque lazy evaluation salta columnas y filas que nunca usas.
- Leer y escribir archivos Parquet, CSV o IPC (Arrow) a escala.
Cuándo NO Usarlo
- Datasets pequeños (<100MB): pandas tiene mejor compatibilidad con el ecosistema y no vas a notar la mejora de velocidad.
- Cuando dependes de librerías nativas de pandas (geopandas, statsmodels, gran parte del preprocessing de scikit-learn).
- Exploración interactiva intensiva en notebooks: la eager evaluation de pandas perdona más cuando estás probando cosas.
- Un equipo que ya domina pandas y donde la velocidad no es el problema.
Solución
Operaciones básicas de DataFrame
import polars as pl
# Leer CSV
df = pl.read_csv("data/orders.csv")
# Leer Parquet
df = pl.read_parquet("data/orders.parquet")
# Crear desde dict
df = pl.DataFrame({
"order_id": [1, 2, 3, 4, 5],
"customer": ["Alice", "Bob", "Alice", "Charlie", "Bob"],
"amount": [100.0, 250.0, 75.5, 300.0, 150.0],
"order_date": ["2025-01-01", "2025-01-02", "2025-01-03", "2025-01-04", "2025-01-05"],
})
# Seleccionar columnas
df.select(["order_id", "amount"])
# Filtrar filas
df.filter(pl.col("amount") > 100)
# Ordenar
df.sort("amount", descending=True)
# Agregar columnas derivadas
df.with_columns([
(pl.col("amount") * 1.1).alias("amount_with_tax"),
pl.col("customer").str.to_uppercase().alias("customer_upper"),
])
# Group by y agregación
df.group_by("customer").agg([
pl.col("amount").sum().alias("total_spent"),
pl.col("order_id").count().alias("order_count"),
pl.col("amount").mean().alias("avg_order"),
])
Lazy evaluation
# Usar scan_* para lazy evaluation (todavía no carga datos)
lf = pl.scan_parquet("data/orders.parquet")
# Construir el query plan; todavía no se ejecuta nada
result = (
lf
.filter(pl.col("amount") > 50)
.with_columns([
pl.col("order_date").str.strptime(pl.Date, "%Y-%m-%d").alias("date"),
(pl.col("amount") * pl.col("quantity")).alias("revenue"),
])
.group_by(["customer", pl.col("date").dt.year().alias("year")])
.agg([
pl.col("revenue").sum().alias("total_revenue"),
pl.col("order_id").n_unique().alias("orders"),
])
.sort("total_revenue", descending=True)
)
# Ejecutar: Polars optimiza el plan y lee solo las columnas necesarias
df = result.collect()
Como el plan es lazy, Polars puede:
- Propagar el filtro
amount > 50hasta el scan, de modo que las filas que no cumplen ni se decodifican. - Leer solo las columnas que se usan después:
order_date,amount,quantity,customer,order_id. - Reordenar joins y fusionar subexpresiones repetidas.
Leer y escribir
# Leer CSV con schema explícito
df = pl.read_csv("data/orders.csv", schema_overrides={
"order_id": pl.Int64,
"amount": pl.Float64,
"order_date": pl.Date,
}, try_parse_dates=True)
# Escribir a Parquet
df.write_parquet("data/output.parquet", compression="zstd")
# Escribir a CSV
df.write_csv("data/output.csv")
# Escribir a IPC (formato Arrow, el round-trip más rápido para Polars)
df.write_ipc("data/output.arrow")
# Leer muchos archivos de una vez
df = pl.scan_csv("data/part-*.csv").collect()
Joins
orders = pl.DataFrame({
"order_id": [1, 2, 3],
"customer_id": [101, 102, 101],
"amount": [100, 200, 150],
})
customers = pl.DataFrame({
"customer_id": [101, 102, 103],
"name": ["Alice", "Bob", "Charlie"],
"city": ["NYC", "LA", "Chicago"],
})
# Inner join
joined = orders.join(customers, on="customer_id", how="inner")
# Left join
joined = orders.join(customers, on="customer_id", how="left")
# Join con nombres de columna distintos
orders = orders.rename({"customer_id": "cust_id"})
joined = orders.join(customers, left_on="cust_id", right_on="customer_id", how="left")
# Join por varias columnas
joined = orders.join(customers, on=["customer_id", "region"], how="inner")
Expresiones condicionales
df = df.with_columns([
pl.when(pl.col("amount") > 200)
.then(pl.lit("high"))
.when(pl.col("amount") > 100)
.then(pl.lit("medium"))
.otherwise(pl.lit("low"))
.alias("tier"),
])
# Mapear valores: replace_strict es la vía rápida para mapeo de valores
df = df.with_columns([
pl.col("status").replace_strict({
"P": "pending",
"C": "completed",
"X": "cancelled",
}, default=pl.col("status")).alias("status_label"),
])
replace_strict lanza error si un valor no tiene mapeo, así que pasa default= para conservar las filas sin mapear. Para lógica compleja por elemento, map_elements(callable) sigue existiendo, pero ejecuta una función de Python por valor y es el camino más lento.
Window functions
df = df.with_columns([
# Total acumulado por cliente
pl.col("amount").cum_sum().over("customer").alias("running_total"),
# Número de fila por cliente ordenado por fecha
pl.col("order_id").rank().over("customer").alias("order_seq"),
# Lag: amount anterior por cliente
pl.col("amount").shift(1).over("customer").alias("prev_amount"),
# Media móvil
pl.col("amount").rolling_mean(window_size=3).over("customer").alias("ma_3"),
])
Concatenación
# Vertical: apilar filas
df_all = pl.concat([df_jan, df_feb, df_mar])
# Horizontal: lado a lado
df_wide = pl.concat([df_left, df_right], how="horizontal")
# Diagonal: rellena columnas faltantes con null
df_combined = pl.concat([df_a, df_b], how="diagonal")
Streaming para datasets grandes
# Stream processing: procesa en lotes con memoria acotada
lf = pl.scan_csv("data/huge_file.csv")
result = (
lf
.filter(pl.col("amount") > 0)
.group_by("customer")
.agg(pl.col("amount").sum())
.sort("amount", descending=True)
)
# engine="streaming" ejecuta la query con memoria acotada
df = result.collect(engine="streaming")
Para que streaming sea el motor por defecto en todas las queries lazy de un proyecto, configura pl.Config.set_engine_affinity(engine="streaming") una vez al arrancar.
Interop con pandas
import pandas as pd
import polars as pl
# pandas a Polars
pdf = pd.read_csv("data.csv")
plf = pl.from_pandas(pdf)
# Polars a pandas
plf = pl.read_csv("data.csv")
pdf = plf.to_pandas()
# Usar Polars para la computación pesada y volver a pandas para graficar
result = (
pl.from_pandas(pdf)
.lazy()
.filter(pl.col("amount") > 100)
.group_by("customer")
.agg(pl.col("amount").sum())
.collect()
.to_pandas()
)
result.plot(kind="bar", x="customer", y="amount")
Interfaz SQL
df = pl.read_parquet("data/orders.parquet")
result = pl.sql("""
SELECT customer, SUM(amount) as total, COUNT(*) as orders
FROM df
WHERE amount > 50
GROUP BY customer
ORDER BY total DESC
""").collect()
Explicación
Cómo funciona el optimizador lazy
pl.scan_* devuelve un LazyFrame: una descripción del trabajo, no de los datos. No se lee nada hasta que llamas a .collect(). Entre ambos, Polars construye un plan lógico y lo reescribe. Las optimizaciones que más pesan en la práctica:
- Predicate pushdown: los filtros bajan hasta el scan. En Parquet, los row groups que no cumplen se saltan antes de decodificar.
- Projection pushdown: solo se leen las columnas que se usan después. En un archivo ancho donde usas 5 de 200 columnas, desaparece casi todo el IO.
- Common subplan elimination: si dos ramas de la query calculan lo mismo, Polars lo calcula una vez.
Puedes inspeccionar el plan antes de ejecutarlo, que es la forma más rápida de confirmar que un filtro sí llegó hasta el scan:
lf = pl.scan_parquet("data/orders.parquet").filter(pl.col("amount") > 50)
print(lf.explain())
# Muestra PARQUET SCAN con el predicado propagado y las columnas proyectadas
Polars vs pandas vs Dask vs Spark
| pandas | Polars | Dask | PySpark | |
|---|---|---|---|---|
| Formato en memoria | NumPy | Apache Arrow | Particiones pandas | JVM / Arrow |
| Ejecución | Eager, un hilo | Eager o lazy, todos los cores | Lazy, multi-máquina | Lazy, cluster |
| Optimizador de queries | No | Sí | Parcial | Catalyst |
| Punto ideal | <1GB, pegamento del ecosistema | 1GB–100GB, un nodo | >100GB, pocos nodos | Cluster existente |
| Datos que exceden RAM | No | engine="streaming" | Spilling a disco | Integrado |
Mi regla general: pandas para exploración y compatibilidad con el ecosistema, Polars cuando la misma máquina debería ir más rápido, Dask o Spark solo cuando los datos de verdad no caben en un nodo. Para las alternativas, mira DataFrames paralelos con Dask y agregaciones group-by en Spark.
Memoria y Arrow
Las columnas de Polars son arrays de Arrow: contiguos, tipados y pensados para memoria compartida. Dos consecuencias prácticas:
- No hay index. La selección de filas pasa por
filter()y expresiones, no por.loc/.iloc. Es lo que más descoloca a quien viene de pandas el primer día. - Interop zero-copy con herramientas que hablan Arrow (DuckDB, PyArrow).
to_pandas()también es barato si pasasuse_pyarrow_extension_array=True, porque pandas 2.x puede sostener columnas Arrow sin copiar.
Como las columnas son contiguas y están alineadas, Polars vectoriza mucho. Buena parte de la velocidad viene de ese layout de memoria, antes incluso de que el optimizador entre en juego.
Drift de schema y versiones
Dos hábitos evitan la mayoría de sorpresas en producción:
- Pasa
schema_overrides(o subeinfer_schema_length) al leer CSVs. La inferencia por defecto muestrea un número limitado de filas, y un archivo donde la fila 50.000 de repente trae un decimal en una columna de enteros rompe la carga. - Fija una versión mayor. Los ejemplos aquí van sobre
polars>=1.0; varias APIs cambiaron en el salto a 1.0 — elcolumnsdepivotpasó aon, ycollect(streaming=True)pasó aengine="streaming".
Para validación más allá de los tipos de columna — rangos de valores, nulabilidad, checks custom — combina Polars con Pandera, que trae backend para Polars.
Variantes
Usar con PyArrow
import pyarrow as pa
import polars as pl
# PyArrow Table a Polars
table = pa.Table.from_pandas(pdf)
plf = pl.from_arrow(table)
# Polars a PyArrow Table
table = plf.to_arrow()
Funciones de agregación custom
# Agregación custom con map_elements (un callable, no un dict)
df.group_by("customer").agg([
pl.col("amount").map_elements(lambda x: x.quantile(0.95)).alias("p95_amount"),
pl.col("amount").std().alias("std_amount"),
pl.col("amount").median().alias("median_amount"),
])
# Agrupar varias columnas en un struct
df.group_by("customer").agg([
pl.struct(["amount", "order_id"]).alias("order_details"),
])
Prefiere pl.col("amount").quantile(0.95) cuando exista una expresión nativa; map_elements es el recurso para lógica que Polars no cubre.
Pivot tables
# Pivot: filas=customer, columnas=month, valores=amount
pivoted = (
df
.with_columns(
pl.col("order_date").str.strptime(pl.Date, "%Y-%m-%d").dt.month().alias("month")
)
.pivot(on="month", index="customer", values="amount", aggregate_function="sum")
)
Mejores Prácticas
- Para una guía más profunda de la alternativa distribuida, consulta Operaciones Paralelas de DataFrame con Dask.
- Usa
scan_*(lazy) en lugar deread_*(eager) para archivos, así el optimizador ve el pipeline completo. - Llama a
.collect()una sola vez al final; cadacollectextra materializa un resultado intermedio. - Filtra temprano en pipelines lazy: un predicado propagado nunca toca la fila.
- Escribe expresiones con
pl.col()en lugar de nombres de columna sueltos para que las llamadas encadenen bien. - Escribe Parquet con
compression="zstd": el mejor equilibrio tamaño/velocidad en la práctica. - Usa
engine="streaming"para datasets que superan la RAM; el streaming engine procesa en lotes con memoria acotada. - Recurre a
replace_strict,when/then/otherwisey expresiones nativas antes quemap_elements; el callback de Python es mucho más lento. - Prefiere window functions con
over()a ordenar y hacer bucles manuales. - Valida las entradas temprano con
schema_overrides; una columna mal tipada contamina todas las expresiones posteriores.
Errores Comunes
- Usar modo eager para archivos grandes:
pl.read_csvcarga todo de golpe.pl.scan_csv(...).collect(engine="streaming")mantiene la memoria acotada. - Convertir a pandas demasiado pronto:
to_pandas()copia datos y pierde los beneficios de Arrow. Quédate en Polars hasta que de verdad necesites una librería que solo existe en pandas. - Pasar un dict a
map_elements: espera un callable, así quemap_elements({"a": 1})lanzaTypeError. Para mapear valores usareplace_strictoreplace. - Copiar snippets de antes de 1.0:
pivot(columns=...)ycollect(streaming=True)se renombraron en Polars 1.0. Comprueba las firmas contra tu versión instalada. - Ignorar
schema_overrides: Polars infiere tipos de CSV desde una muestra de filas, y en archivos grandes la muestra pierde casos borde. Declara los tipos explícitamente. - Mantener
map_elementsen bucles calientes: un callback de Python por elemento es el camino lento; las expresiones nativas vectorizan.
Resumen
- Polars es un motor de DataFrame de un solo nodo: memoria Arrow, todos los cores, optimizador lazy opcional.
- Empieza lazy con
scan_*, construye un solo plan y llama acollect()una vez. Cambia aengine="streaming"cuando los datos superan la RAM. - Las diferencias de API que más muerden a quien viene de pandas: expresiones
pl.col()en lugar de indexado de columnas, no hay index, yreplace_stricten lugar de.map(dict). - Los ejemplos van sobre
polars>=1.0; los snippets anteriores a 1.0 que encuentras por ahí usan otros nombres de parámetro.
See Also
- Polars user guide: documentación oficial del lazy API, streaming engine y referencia de expresiones.
- Polars Python API reference: firmas actuales de
pivot,replace_strict,collect(engine=...). - Updated PDS-H benchmark results: los números oficiales detrás de la comparación con pandas del Overview.
- Apache Arrow columnar format: el modelo de memoria sobre el que se construye Polars.
Preguntas frecuentes
¿En qué se diferencia Polars de pandas?
Polars usa columnas Apache Arrow (zero-copy, contiguas), no tiene index, ofrece un motor de queries lazy y paraleliza en todos los cores. pandas usa arrays NumPy, lleva un index, evalúa en eager y es mayormente de un solo hilo.
¿Polars reemplaza a pandas?
Para procesamiento de datos, en la mayoría de casos sí. Para compatibilidad con el ecosistema (scikit-learn, geopandas, statsmodels) pandas sigue haciendo falta, y to_pandas() / from_pandas() hacen que cruzar la frontera sea barato.
¿Cómo funciona lazy evaluation?
scan_* construye un query plan en lugar de leer datos. Cuando llamas a .collect(), Polars aplica predicate pushdown, projection pushdown y reordenamiento de joins, y después ejecuta. Se lee y decodifica menos data, y ahí es donde está la mayor parte de la ganancia.
¿Polars maneja datasets más grandes que la memoria?
Sí. Llama a .collect(engine="streaming"): el streaming engine ejecuta el plan en lotes con memoria acotada. En el benchmark PDS-H oficial aguantó a scale factor 100 donde el engine in-memory quedó limitado por memoria.
¿Qué versión de Polars necesitan estos ejemplos?
polars>=1.0. En 0.20.x vas a ver firmas antiguas: pivot recibe columns= en lugar de on=, y el streaming es collect(streaming=True).
¿Cómo migro de pandas a Polars?
Empieza por un hot path: cambia pd.read_csv por pl.read_csv y df.groupby().agg() por df.group_by().agg(). La expression API (pl.col()) sustituye al acceso directo a columnas. pl.from_pandas() y to_pandas() te dejan migrar un pipeline etapa a etapa en lugar de reescribirlo entero.
Recursos Relacionados
Construir un Pipeline ETL con pandas y Parquet
Cómo construir un pipeline extract-transform-load con pandas para procesar datos y Parquet para almacenamiento columnar con validación de tipos.
RecipeOperaciones Paralelas de DataFrame con Dask
Escalá workflows de pandas con Dask. Procesá DataFrames que no caben en memoria usando lazy evaluation, particiones y el scheduler distribuido en datasets de 1 GB a 1 TB.
RecipeAgregaciones a Gran Escala con PySpark
Cómo realizar agregaciones group-by en datasets grandes con PySpark, cubriendo window functions, UDFs, broadcast joins y tuning de performance.
RecipeValidar schemas de DataFrame con Pandera
Cómo validar schemas de DataFrames de pandas y Polars con Pandera: tipos de columnas, constraints, checks personalizados, hypothesis testing y herencia de schemas.