polarspyarrowpandaspythonetldata-engineeringperformance

Pandas se quedó corto con 10M filas — cómo migré a Polars y lo que cambió

Pandas funciona. Para datasets pequeños (<1M filas), scripts exploratorios, y prototipos, sigue siendo la herramienta correcta — el ecosistema, la documentación, la cantidad de ejemplos disponibles son imbatibles.

Pero cuando los datasets crecen, Pandas muestra sus costuras. El modo eager (evalúa todo inmediatamente), el uso de NumPy bajo el capó (no Arrow), la falta de paralelismo nativo — a escala, estos factores se vuelven cuellos de botella reales.

Migré mis pipelines ETL a Polars + PyArrow. Este post explica cuándo valió la pena y cuándo no.

El problema con Pandas a escala

import pandas as pd

# Pandas — todo en memoria, evaluación inmediata
df = pd.read_csv("datos_grandes.csv")              # Carga todo en RAM
df_filtrado = df[df["status"] == "active"]         # Nueva copia en RAM
df_agrupado = df_filtrado.groupby("region").agg({  # Otra operación en RAM
    "monto": "sum",
    "id": "count",
})

Tres problemas:

  1. Cada operación puede crear una copia del DataFrame — uso de memoria 2-3x el tamaño del archivo
  2. pd.read_csv es single-threaded — 1 core para leer, el resto esperan
  3. Sin optimización de queries — ejecuta cada operación en el orden que tú escribes, sin reordenar

Para un CSV de 500MB, esto puede significar 1-2GB de RAM y 30-60 segundos de procesamiento.

Polars: lazy evaluation y ejecución paralela

import polars as pl

# Polars lazy — construye el plan de ejecución sin ejecutar
query = (
    pl.scan_csv("datos_grandes.csv")              # scan, no read
      .filter(pl.col("status") == "active")
      .group_by("region")
      .agg([
          pl.col("monto").sum(),
          pl.col("id").count(),
      ])
)

# Nada se ejecuta hasta .collect()
# Polars optimiza el plan antes de ejecutar:
# - Predicate pushdown: aplica el filter durante la lectura, no después
# - Projection pushdown: lee solo las columnas necesarias
# - Paralelismo automático: usa todos los cores disponibles
result = query.collect()

pl.scan_csv vs pd.read_csv: con scan_csv, Polars no lee el archivo — construye un plan. Al hacer .collect(), Polars optimiza el plan completo y lo ejecuta en paralelo.

El mismo pipeline en un CSV de 500MB:

Expresiones vs operaciones en lugar de loops

La diferencia de paradigma más importante entre Pandas y Polars:

# Pandas — apply con función Python (lento — salida de las optimizaciones nativas)
df["categoria"] = df["monto"].apply(lambda x: "alto" if x > 10000 else "bajo")

# Polars — expresión nativa (rápido — SIMD, paralelo)
df = df.with_columns(
    pl.when(pl.col("monto") > 10000)
      .then(pl.lit("alto"))
      .otherwise(pl.lit("bajo"))
      .alias("categoria")
)

El .apply() de Pandas es un loop de Python disfrazado — para cada fila, llama a la función Python. Polars con pl.when().then().otherwise() compila la expresión y la ejecuta en Rust con SIMD y paralelismo.

Para transformaciones complejas con múltiples condiciones:

# Polars — múltiples condiciones en una expresión
df = df.with_columns([
    pl.when(pl.col("monto") > 100_000)
      .then(pl.lit("enterprise"))
      .when(pl.col("monto") > 10_000)
      .then(pl.lit("medium"))
      .otherwise(pl.lit("small"))
      .alias("tier"),

    # String operations son expresiones también
    pl.col("nombre")
      .str.to_lowercase()
      .str.strip_chars()
      .alias("nombre_normalizado"),

    # Date operations
    pl.col("created_at")
      .dt.strftime("%Y-%m")
      .alias("mes"),
])

Todas estas transformaciones se ejecutan en paralelo sobre el DataFrame completo.

PyArrow como formato de intercambio

PyArrow es el puente entre Polars, Delta Lake, Parquet, y otros sistemas del ecosistema Arrow:

import pyarrow as pa
import pyarrow.parquet as pq
from deltalake import write_deltalake

# Polars → PyArrow → Delta Lake
polars_df = pl.DataFrame({"id": [1, 2, 3], "valor": [10.0, 20.0, 30.0]})
arrow_table = polars_df.to_arrow()

write_deltalake("path/to/delta", arrow_table)

# PyArrow → Polars (lectura desde Parquet directo)
arrow_table = pq.read_table("datos.parquet")
polars_df = pl.from_arrow(arrow_table)

# Delta Lake → Polars (via PyArrow)
from deltalake import DeltaTable
dt = DeltaTable("path/to/delta")
polars_df = pl.from_arrow(dt.to_pyarrow())

La conversión entre Polars y Arrow es zero-copy cuando los tipos son compatibles — no copia memoria, solo comparte el buffer subyacente.

Schema enforcement con PyArrow

Para pipelines donde el schema del input puede variar, PyArrow permite validar y castear:

import pyarrow as pa

# Schema esperado
expected_schema = pa.schema([
    pa.field("id", pa.int64()),
    pa.field("nombre", pa.string()),
    pa.field("monto", pa.float64()),
    pa.field("created_at", pa.timestamp("us", tz="UTC")),
])

def validate_and_cast(arrow_table: pa.Table) -> pa.Table:
    # Verificar que todas las columnas existen
    missing = set(expected_schema.names) - set(arrow_table.schema.names)
    if missing:
        raise ValueError(f"Missing columns: {missing}")

    # Castear columnas al tipo esperado
    arrays = []
    for field in expected_schema:
        col = arrow_table[field.name]
        if col.type != field.type:
            col = col.cast(field.type)
        arrays.append(col)

    return pa.table(
        dict(zip(expected_schema.names, arrays)),
        schema=expected_schema,
    )

El cast explícito previene que un int32 del origen se escriba como int32 en Delta Lake cuando el schema espera int64 — diferencia invisible en datos pequeños, problema real cuando el rango se excede.

Lectura eficiente con filtros (predicate pushdown a Parquet)

Polars puede pushear filtros directamente al nivel de Parquet — lee solo los row groups necesarios:

# Scan de Parquet con predicate pushdown
result = (
    pl.scan_parquet("datos/*.parquet")
      .filter(
          (pl.col("year") == 2024) &
          (pl.col("region") == "LATAM")
      )
      .select(["id", "monto", "created_at"])  # Solo columnas necesarias
      .collect()
)

Parquet almacena estadísticas (min, max) por row group. Polars usa esas estadísticas para saltar row groups que no cumplen el filtro — sin leer los datos. Para datos particionados por año, leer solo 2024 puede saltar el 75% del archivo.

Cuándo Pandas sigue siendo la opción correcta

Polars no reemplaza Pandas en todos los casos:

Exploración interactiva en Jupyter: El ecosistema de visualización (matplotlib, seaborn, plotly) consume DataFrames de Pandas. Convertir siempre agrega fricción.

Integración con scikit-learn: La mayoría de estimadores de scikit-learn esperan arrays NumPy o DataFrames de Pandas. polars_df.to_pandas() funciona, pero agrega un paso.

Datasets pequeños (<100K filas): El overhead de Polars para optimizar el plan de ejecución no vale para datos pequeños. Pandas es más rápido en datasets donde no hay mucho que optimizar.

Código de equipo con experiencia Pandas: La curva de aprendizaje de Polars es real, especialmente las expresiones y el modo lazy. Si el equipo no tiene tiempo de aprenderlo, el overhead cognitivo cancela las ganancias de performance.

Lo que aprendí

El modo lazy no es opcional para ETL. El predicate pushdown y projection pushdown de Polars lazy pueden reducir el tiempo de lectura de un factor de 5-10x en archivos grandes. Usar pl.scan_* en lugar de pl.read_* siempre que el input sea archivo.

apply en Pandas es el primer lugar a revisar en un pipeline lento. Si hay un .apply(lambda ...), hay una oportunidad de reescribir como expresión nativa — en Polars o en Pandas mismo con .map + vectorización.

PyArrow como capa de intercambio evita conversiones redundantes. Polars → Arrow → Delta Lake es zero-copy. Polars → Pandas → Arrow → Delta Lake hace dos copias innecesarias.

Los tipos importan desde el inicio. Definir el schema esperado antes de escribir a Delta Lake previene acumulación de inconsistencias de tipos que después son costosas de corregir en producción.

Volver al blog