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.
Cuando un DataFrame no cabe en RAM con Pandas, cambiar a Polars suele resolverlo sin tocar la máquina: Polars, escrito en Rust sobre Apache Arrow, lee el archivo en modo perezoso con scan_csv() en vez de cargarlo entero, construye un plan de consulta que solo trae a memoria las columnas y filas que el filter() y el group_by() final realmente necesitan, y paraleliza esa lectura en todos los núcleos del CPU. En un dataset de unos 8 millones de filas eso bajó un groupby de 41 segundos y 7,4 GB de RAM en Pandas a 1,8 segundos y 480 MB en Polars. La regla práctica: por debajo de 100 MB la diferencia no se nota y Pandas tiene mejor ecosistema; por encima de 1 GB, Polars en modo perezoso con scan_csv() o scan_parquet() es la opción, salvo que el equipo no lo conozca o el paso siguiente dependa de una librería que solo habla Pandas.
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)
Unos 8 millones de filas. CSV de casi 2 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.
Preguntas frecuentes
¿Y si los datos no vienen de un CSV sino de una conexión a Postgres directamente?
Polars puede leer directamente de una base de datos con pl.read_database(), así que no hace falta pasar por un CSV intermedio. La ventaja de la lectura perezosa se reduce si la propia base de datos no filtra antes de entregar los datos, así que conviene mover el WHERE a la consulta SQL cuando se pueda, no dejarlo todo para el .filter() de Polars.
¿Se comporta igual group_by cuando hay claves con valores nulos?
No exactamente como en Pandas: por defecto Polars mantiene las filas con clave nula y las agrupa aparte, en vez de descartarlas. Si esperas que las filas con event_id nulo desaparezcan del agregado, hace falta un .drop_nulls() explícito antes del group_by, o aparece una fila de más con etiqueta nula en el resultado.
¿Compensa reescribir a Polars un script que ya funciona bien y corre poco tiempo al día?
Casi nunca. Si el script corre una vez al día y tarda 40 segundos, reescribirlo cuesta más tiempo humano del que se ahorra en meses de ejecuciones. Compensa cuando el script corre muchas veces al día, cuando alguien lo espera en directo, o cuando ya empieza a fallar por memoria, no solo porque Polars sea más rápido sobre el papel.