Pandas + Polars: cuando tu DataFrame ya no cabe en RAM
El día que abrí un CSV de 8 millones de filas en Pandas y mi portátil cayó. Cómo Polars me lo agregó en 1,8 segundos sin tocar la RAM, y por qué no uso solo Polars.
Hubo un punto en el que mis scripts de análisis empezaron a dejar de funcionar. No por bug. Por RAM. Un CSV de ventas de un año entero, 8 millones de filas, abierto con pd.read_csv(...): 6,2 GB de memoria solo para cargarlo. Mi portátil con 16 GB se ahogaba en cuanto intentaba un groupby no trivial. Reiniciar Python cada cinco minutos no es una metodología.
Entró Polars. Y cambió las reglas.
#El problema en concreto
Imagina el caso: necesito el GMV por evento y por día, ordenado por fecha, de un dataset con esta forma:
transaction_id, event_id, ticket_type, price_eur, channel,
country, postal_code, created_at, status, ... (18 cols)
8.124.973 filas. CSV de 1,9 GB en disco. Con Pandas estándar:
import pandas as pd
df = pd.read_csv("ventas_2025.csv") # 6,2 GB RAM, 28 segundos
out = (
df[df["status"] == "completed"]
.groupby([df["created_at"].dt.date, "event_id"])
.agg(gmv=("price_eur", "sum"), n=("price_eur", "size"))
.reset_index()
)
# Tiempo total: 41 segundos. RAM pico: 7,4 GB.41 segundos no es el infierno. El problema es que estoy fundiendo el portátil para una operación que conceptualmente es trivial: SUM por día y evento.
#La misma operación en Polars
import polars as pl
out = (
pl.scan_csv("ventas_2025.csv") # lazy, no carga nada
.filter(pl.col("status") == "completed")
.with_columns(pl.col("created_at").str.to_datetime().dt.date().alias("day"))
.group_by(["day", "event_id"])
.agg(
pl.col("price_eur").sum().alias("gmv"),
pl.len().alias("n"),
)
.sort("day")
.collect() # ahora sí ejecuta
)
# Tiempo total: 1,8 segundos. RAM pico: 480 MB.23 veces más rápido. 15 veces menos RAM. Sin truco.
Pandas carga TODO en memoria, en un único hilo. Polars (escrito en Rust sobre Apache Arrow) hace tres cosas a la vez: paraleliza por columnas en todos los núcleos del CPU, evalúa lazy (encadena operaciones y solo ejecuta lo necesario), y usa columnar storage que es más eficiente en cache y compresión.
#Cuándo merece la pena Polars
No siempre. La regla que uso:
| Tamaño dataset | Herramienta |
|---|---|
| < 100 MB | Pandas. La diferencia es invisible y el ecosistema es más maduro. |
| 100 MB – 1 GB | Pandas si es one-shot; Polars si vas a iterar muchas veces. |
| 1 GB – 50 GB | Polars con scan_* (lazy). Aquí es donde brilla. |
| > 50 GB | Polars streaming, o DuckDB sobre el CSV directo, o cargar a Postgres. |
Y dos casos en los que no uso Polars aunque encaje el tamaño:
- Cuando el equipo lo va a tocar y no conoce Polars. El API es similar a Pandas pero no idéntico. Si yo me voy y el siguiente solo sabe Pandas, dejo el código en Pandas.
- Cuando necesito un ecosistema específico (statsmodels, scikit-learn, geopandas). Estos esperan Pandas. Convierto al final con
df.to_pandas().
#El patrón híbrido que uso a diario
Cargar y pre-procesar con Polars (rápido y barato). Pasar a Pandas solo al final, para los pasos que necesiten librerías del ecosistema:
import polars as pl
# Trabajo pesado en Polars
clean = (
pl.scan_csv("ventas_2025.csv")
.filter(pl.col("status") == "completed")
.with_columns(
pl.col("created_at").str.to_datetime(),
pl.col("price_eur").cast(pl.Float64),
)
.group_by("event_id")
.agg(
gmv = pl.col("price_eur").sum(),
n = pl.len(),
avg = pl.col("price_eur").mean(),
p95 = pl.col("price_eur").quantile(0.95),
)
.collect()
)
# Salida a Pandas solo para lo que requiere el ecosistema
df = clean.to_pandas()
df.plot.scatter(x="n", y="avg") # matplotlib quiere Pandas#El truco real: scan_csv y lazy execution
Lo que hace especial a Polars no es solo Rust. Es la separación entre scan_* y collect(). Cuando escribes:
pl.scan_csv("...").filter(...).group_by(...).agg(...).collect()Polars no lee el CSV de inmediato. Construye un query plan, lo optimiza (push down de filtros, eliminación de columnas no usadas, paralelización), y solo entonces lee del disco. Si tu agregado solo necesita 3 columnas de 18, Polars solo lee esas 3.
Compáralo con Pandas: pd.read_csv lee TODO siempre, luego tú filtras en memoria.
#Conversión rápida: Pandas → Polars
Si tienes un script Pandas que quieres migrar, esta tabla cubre el 80%:
| Pandas | Polars |
|---|---|
pd.read_csv("x.csv") | pl.read_csv("x.csv") o pl.scan_csv("x.csv") |
df[df["col"] > 5] | df.filter(pl.col("col") > 5) |
df.groupby("a").agg(...) | df.group_by("a").agg(...) |
df["new"] = df["a"] + df["b"] | df.with_columns((pl.col("a") + pl.col("b")).alias("new")) |
df.sort_values("date") | df.sort("date") |
df.dropna() | df.drop_nulls() |
df.merge(other, on="id") | df.join(other, on="id") |
El gotcha más común: Polars no tiene índice como Pandas. Olvídate de set_index. Todo es columnar y sin índices implícitos. En la práctica es liberador.
#Cuándo no me sirve Polars
Honestidad obligatoria: hay casos en los que Polars me ha decepcionado.
- JSON anidado complejo. Polars puede leer JSON, pero estructuras profundas con arrays de objetos no son su fuerte. Pandas con
json_normalizesigue siendo más cómodo. - Series temporales con frecuencias raras. Pandas tiene
resample("Q"),resample("W-MON"), etc. Polars tienegroup_by_dynamicpero con menos azúcar. - Plotting directo. Polars no tiene
.plot()integrado. O conviertes a Pandas o usas plotnine / matplotlib manualmente.
Si tu dataframe es < 100 MB: Pandas. Si es > 1 GB: Polars con scan_*. Entre medias: depende. Y para lo que falta ecosistema (statsmodels, geopandas, plotting), .to_pandas() al final y listo.
En el próximo post hago el comparativo directo Python vs R para análisis. Spoiler: con Polars en la mesa, R deja de tener ventaja clara en datasets grandes. Pero sigue ganando para EDA rápido y modelado estadístico.