Polars vs pandas

Polars vs pandas: comparación lado a lado

Esta sección muestra las operaciones más comunes en ambas librerías, lado a lado, con explicaciones de por qué la API es diferente — no solo cómo es diferente.

Leer datos

# pandas
df = pd.read_csv("datos.csv")                  # lee TODO inmediatamente
df = pd.read_csv("datos.csv", usecols=["a","b"])  # selección manual de columnas

# polars — eager
df = pl.read_csv("datos.csv")                   # lee TODO inmediatamente

# polars — lazy (recomendado)
lf = pl.scan_csv("datos.csv")                   # NO lee nada
result = lf.select("a", "b").collect()           # projection pushdown automático

¿Por qué? En pandas, si quieres leer solo 2 columnas debes decirlo en read_csv. En Polars lazy, el optimizador detecta qué columnas usas en todo el pipeline y solo lee esas.

Seleccionar columnas

# pandas
df[["nombre", "edad"]]                  # indexación con lista
df.loc[:, ["nombre", "edad"]]           # loc explícito

# polars
df.select("nombre", "edad")            # método explícito
df.select(pl.col("nombre", "edad"))     # con expresión

¿Por qué? Polars evita la ambigüedad de df[x] que en pandas puede ser una columna (string), varias (lista), o filas (slice).

Crear / modificar columnas

# pandas
df["salario_anual"] = df["salario"] * 12          # modifica in-place
df = df.assign(salario_anual=df["salario"] * 12)  # retorna copia

# polars
df = df.with_columns(
    (pl.col("salario") * 12).alias("salario_anual")
)

¿Por qué? Polars es inmutable — with_columns siempre retorna un DataFrame nuevo. No hay SettingWithCopyWarning porque no hay asignación in-place.

Filtrar filas

# pandas
df[df["edad"] > 30]                             # máscara booleana
df.query("edad > 30")                           # string query

# polars
df.filter(pl.col("edad") > 30)                  # expresión
df.filter(
    (pl.col("edad") > 30) & (pl.col("ciudad") == "Madrid")
)

¿Por qué? Las expresiones son objetos que el optimizador puede analizar. La indexación booleana de pandas es opaca — pandas no puede “ver dentro” de df[mask].

Agrupar y agregar

# pandas
df.groupby("ciudad").agg(
    salario_medio=("salario", "mean"),
    n=("nombre", "count"),
)

# polars
df.group_by("ciudad").agg(
    pl.col("salario").mean().alias("salario_medio"),
    pl.col("nombre").count().alias("n"),
)

¿Por qué? En Polars, cada agregación es una expresión completa — puedes hacer transformaciones complejas dentro del agg:

# polars — imposible en una sola línea de pandas
df.group_by("ciudad").agg(
    pl.col("salario").filter(pl.col("edad") > 30).mean().alias("salario_senior"),
    (pl.col("salario").max() - pl.col("salario").min()).alias("rango"),
    pl.col("nombre").sort().first().alias("primer_nombre_alfa"),
)

Joins

# pandas
pd.merge(df1, df2, on="id", how="left")
# o
df1.merge(df2, on="id", how="left")

# polars
df1.join(df2, on="id", how="left")

La API es similar, pero Polars usa hash joins paralelos internamente. En modo lazy, el optimizador puede hacer projection pushdown en ambos lados del join.

Apply / Map

# pandas — apply fila por fila (LENTO)
df["resultado"] = df["texto"].apply(lambda x: x.upper().strip())
# pandas — vectorizado (RÁPIDO)
df["resultado"] = df["texto"].str.upper().str.strip()

# polars — expresión (RÁPIDO, Rust)
df = df.with_columns(
    pl.col("texto").str.to_uppercase().str.strip_chars().alias("resultado")
)
# polars — map_elements (LENTO, Python, último recurso)
df = df.with_columns(
    pl.col("texto").map_elements(
        lambda x: x.upper().strip(),
        return_dtype=pl.String
    ).alias("resultado")
)

¿Por qué? Tanto en pandas como en Polars, .apply() / map_elements es lento porque ejecuta Python fila por fila. La diferencia: en Polars, las expresiones nativas (.str.*, .dt.*, etc.) son mucho más completas, así que necesitas map_elements con menos frecuencia.

Operaciones de texto

# pandas
df["nombre"].str.lower()
df["nombre"].str.contains("alice")
df["nombre"].str.replace("old", "new")
df["nombre"].str.split(",")
df["nombre"].str.len()

# polars
pl.col("nombre").str.to_lowercase()
pl.col("nombre").str.contains("alice")
pl.col("nombre").str.replace("old", "new")
pl.col("nombre").str.split(",")            # retorna List[String]
pl.col("nombre").str.len_chars()

¿Por qué? pandas opera sobre objetos Python (dtype object). Polars opera sobre Arrow Utf8 en Rust. La API es similar, pero el rendimiento difiere 5-50x en operaciones de texto pesadas.

Operaciones temporales

# pandas
df["fecha"] = pd.to_datetime(df["fecha_str"])
df["anio"] = df["fecha"].dt.year
df["mes"] = df["fecha"].dt.month
df["dia_semana"] = df["fecha"].dt.dayofweek

# polars
df = df.with_columns(
    pl.col("fecha_str").str.to_date("%Y-%m-%d").alias("fecha"),
).with_columns(
    pl.col("fecha").dt.year().alias("anio"),
    pl.col("fecha").dt.month().alias("mes"),
    pl.col("fecha").dt.weekday().alias("dia_semana"),
)

¿Por qué? En pandas, pd.to_datetime intenta adivinar el formato (lento). En Polars, especificas el formato (rápido y predecible).

Window functions

# pandas — rank dentro de grupo
df["rank"] = df.groupby("grupo")["valor"].rank()

# polars — .over() como window function
df = df.with_columns(
    pl.col("valor").rank().over("grupo").alias("rank")
)

over() es el equivalente Polars de una window function SQL — aplica la expresión particionada por grupo, sin colapsar filas.

# Más ejemplos de .over()
df.with_columns(
    pl.col("valor").mean().over("grupo").alias("media_grupo"),
    pl.col("valor").sum().over("grupo").alias("total_grupo"),
    (pl.col("valor") - pl.col("valor").mean().over("grupo")).alias("desv_de_media"),
)

Valores faltantes

# pandas
df["col"].isna()                    # NaN check
df["col"].fillna(0)                 # reemplazar
df.dropna(subset=["col"])           # eliminar filas

# polars
pl.col("col").is_null()             # null check (no NaN)
pl.col("col").fill_null(0)          # reemplazar
df.drop_nulls(subset=["col"])       # eliminar filas

¿Por qué? pandas usa NaN (float IEEE 754) para representar datos faltantes — un entero con un null se convierte a float. Polars usa nulls nativos de Arrow con validity bitmap — el tipo se preserva.

Pivot / Unpivot

# pandas — pivot
df.pivot_table(index="fecha", columns="producto", values="ventas", aggfunc="sum")

# polars — pivot
df.pivot(on="producto", index="fecha", values="ventas", aggregate_function="sum")

# pandas — melt (unpivot)
pd.melt(df, id_vars=["fecha"], value_vars=["prod_a", "prod_b"])

# polars — unpivot
df.unpivot(index="fecha", on=["prod_a", "prod_b"])

Encadenar operaciones

# pandas — pipe o paréntesis
resultado = (
    df
    .query("precio > 100")
    .assign(total=lambda d: d["precio"] * d["cantidad"])
    .groupby("categoria")
    .agg(revenue=("total", "sum"))
    .sort_values("revenue", ascending=False)
)

# polars — encadenamiento natural con lazy
resultado = (
    pl.scan_csv("datos.csv")
    .filter(pl.col("precio") > 100)
    .with_columns(
        (pl.col("precio") * pl.col("cantidad")).alias("total")
    )
    .group_by("categoria").agg(
        pl.col("total").sum().alias("revenue")
    )
    .sort("revenue", descending=True)
    .collect()
)

¿Por qué? El encadenamiento en Polars es más natural porque cada método retorna un LazyFrame. No necesitas lambda ni pipe — cada operación es una expresión.

Tabla resumen

Operación pandas polars Ventaja Polars
Leer CSV read_csv scan_csv (lazy) Projection/predicate pushdown
Seleccionar df[cols] df.select(cols) Sin ambigüedad
Crear columna df["x"] = ... with_columns(expr) Inmutable, sin warnings
Filtrar df[mask] df.filter(expr) Optimizable
Agrupar groupby().agg() group_by().agg() Expresiones en agg
Join merge() join() Hash join paralelo
Apply .apply() map_elements() Más expresiones nativas
Strings .str.* (object) .str.* (Arrow Utf8) 5-50x más rápido
Fechas .dt.* .dt.* Formato explícito
Window groupby().transform() .over() Sintaxis SQL natural
Nulls NaN (float) Arrow null (tipado) Tipos preservados

Verifica en el notebook: Notebook 03 ejecuta cada una de estas comparaciones con código real, midiendo tiempos cuando la diferencia de rendimiento es significativa.