Tutorial Polars 2026 para reemplazar Pandas en Python con DataFrames rápidos, lazy evaluation y streaming sobre Apache Arrow
Volver al blog
TUTORIALES 23 Julio 2026 20 min lectura 14 visitas

Tutorial completo Polars 2026: DataFrames rápidos en Python que reemplazan a Pandas paso a paso

Arkaia Editorial
Arkaia Editorial Editor

Polars es la librería DataFrame escrita en Rust que en 2026 ya no es una promesa: es la elección por defecto de miles de equipos de data engineering y ciencia de datos que estaban hartos de los cuellos de botella de Pandas con datasets medianos y grandes. Su versión 1.43.0 (publicada el 21 de julio de 2026) consolida un motor de consultas con evaluación perezosa, ejecución multihilo por defecto, integración nativa con Apache Arrow, Parquet e Iceberg, y una API expresiva basada en expressions que permite optimizaciones que la API imperativa de Pandas nunca podrá hacer.

Los benchmarks independientes de 2026 dan diferencias de 5 a 15 veces en operaciones de join, group by y agregación sobre datasets a partir de 1 GB, y una diferencia todavía mayor cuando entra en juego el modo streaming para procesar ficheros que no caben en RAM. Pandas necesita típicamente entre 5 y 10 veces el tamaño del dataset en memoria; Polars vive con 2 a 4 veces. Y sin embargo Polars no es una copia mejorada de Pandas: el rediseño de la API es lo que le permite ir tan rápido, así que migrar exige aprender un modelo mental nuevo. Este tutorial te lleva de cero a productivo en nueve pasos con código Python real ejecutable, buenas prácticas y un benchmark reproducible al final.

Tutorial Polars 2026 para reemplazar Pandas en Python con DataFrames rápidos, lazy evaluation y streaming sobre Apache Arrow
Polars 1.43 (julio 2026): DataFrames columnares en Rust con lazy evaluation, streaming y expressions que baten a Pandas 5 a 15 veces en operaciones típicas.

Por qué Polars está reemplazando a Pandas en 2026

Pandas nació en 2008 pensada para datasets que caben cómodamente en la RAM de un portátil. Diecisiete años después la realidad es otra: los ficheros Parquet de producción rondan decenas de gigabytes, los pipelines de feature engineering encadenan cientos de operaciones y las CPUs de escritorio traen 16 o más núcleos que Pandas simplemente no aprovecha. Polars ataca los cuatro problemas estructurales de Pandas con decisiones de diseño distintas.

Ejecución multihilo por defecto. Pandas es single threaded salvo que uses trucos externos (Dask, Modin, Numba). Polars paraleliza cada operación entre todos los núcleos de la CPU sin que tengas que pedirlo. Un group by sobre 10 millones de filas en un Ryzen 9 moderno usa los 16 núcleos, no uno.

Modelo columnar sobre Apache Arrow. Pandas mezcla su propio backend con NumPy y sufre en tipos string, categóricos y fechas. Polars usa Arrow de arriba abajo: los strings viven en UTF-8 views, los enteros y flotantes se agrupan en chunks contiguos, y compartir datos con DuckDB, PyIceberg o Spark cuesta cero copias.

Lazy evaluation con optimizador de consultas. Cuando llamas a scan_parquet en vez de read_parquet, Polars no lee nada; construye un plan lógico. Cuando finalmente llamas a collect(), un optimizador reordena filtros, empuja proyecciones al escaneo (predicate y projection pushdown), fusiona operaciones y elige el plan físico. Es lo que hacen DuckDB y Spark; Pandas nunca lo va a hacer porque su API imperativa lo impide.

API basada en expressions. En Pandas escribes df[df.x > 0].y.mean(): imperativo, ambiguo (dos veces referencia df) y difícil de optimizar. En Polars escribes df.select(pl.col("y").filter(pl.col("x") > 0).mean()): cada operación es un objeto Expression que el motor puede analizar, reescribir y paralelizar. La curva de aprendizaje es real pero pequeña, y el retorno es enorme.

La consecuencia práctica es que Polars encaja perfecto en el patrón moderno de 2026: DuckDB para SQL sobre ficheros, Polars para pipelines de ETL (joins, group bys, window functions, feature engineering) y Pandas al final, en la frontera con scikit-learn, statsmodels y las librerías de visualización que todavía esperan un pd.DataFrame.

Requisitos previos: software y hardware

Para seguir el tutorial sin fricciones necesitas cinco cosas.

  • Python 3.11 o superior. Polars 1.x requiere 3.10 como mínimo, pero recomendamos 3.11 o 3.12 por el speedup del intérprete y por compatibilidad con la mayoría del ecosistema data. Comprueba con python --version.
  • Gestor de paquetes moderno. Recomendamos uv (rapidísimo, escrito también en Rust); pip funciona igual de bien. Instala uv con curl -LsSf https://astral.sh/uv/install.sh | sh.
  • Editor con soporte Python decente. VS Code con Pylance, Cursor, PyCharm o Neovim con basedpyright. Polars se aprovecha mucho del tipado.
  • CPU moderna con varios núcleos. Polars paraleliza por defecto, así que la mejora es proporcional a los núcleos disponibles. En un Ryzen 9 o un Apple M4 el salto respecto a Pandas es brutal.
  • SSD NVMe rápido si vas a trabajar con Parquet grandes. El cuello de botella del streaming es a menudo el disco, no la CPU.

Sobre hardware, esta es la configuración que usamos en Arkaia para pipelines Polars serios y con la que hemos validado los benchmarks del paso 9 (todos los enlaces son afiliados Amazon con tag webmasteroson-21):

  • Apple Mac mini M4 (16 GB / 512 GB SSD): máquina compacta y silenciosa con NPU integrada, ideal para desarrollo local de pipelines Polars y para correr modelos locales en Ollama en paralelo. Ver precio actual en Amazon.
  • Apple Mac mini M4 (16 GB / 256 GB SSD): variante más económica del anterior, suficiente para notebooks Polars sin datasets locales enormes. Ver precio actual en Amazon.
  • Monitor BenQ RD280U 4K: 4K con relación 3:2 y modo Coding; permite ver notebooks largos con salida tabular ancha sin scroll horizontal. Ver precio actual en Amazon.
  • Monitor LG 29WP60G-B ultrawide: 29 pulgadas panorámico para tener editor, terminal y navegador con la documentación de Polars en paralelo. Ver precio actual en Amazon.
  • Teclado Logitech MX Keys: teclas silenciosas retroiluminadas, imprescindible en sesiones largas escribiendo expressions anidadas. Ver precio actual en Amazon.
  • Ratón Logitech MX Master 3S: scroll ultrarrápido para revisar salidas largas de describe() y notebooks Jupyter. Ver precio actual en Amazon.
  • Silla ergonómica Secretlab Titan Evo: soporte lumbar magnético para jornadas largas de análisis y ETL sin castigar la espalda. Ver precio actual en Amazon.

Paso 1: Instalación de Polars con uv o pip

Polars se distribuye como una wheel autocontenida: no compila nada en tu máquina, no requiere Rust instalado, no depende de C++ ni de BLAS. Instalar es literalmente una línea. Con uv:

mkdir tutorial-polars && cd tutorial-polars
uv venv .venv
source .venv/bin/activate  # en Windows: .venv\Scripts\activate

uv add polars              # instalacion basica
uv add "polars[all]"       # con todos los extras (pyarrow, numpy, pandas, pydantic, etc.)

Con pip clásico:

python -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install polars           # basico
pip install "polars[all]"    # todos los extras

Variantes que conviene conocer. Polars publica tres wheels distintas por versión, y elegir mal puede tirarte el rendimiento a la basura o directamente hacer que no arranque:

  • polars: la wheel por defecto. Requiere una CPU con instrucciones AVX2 (todo lo posterior a 2013 en x86, todos los Apple Silicon en ARM). Es la más rápida.
  • polars-lts-cpu: Long Term Support. Sin AVX2 ni AVX512, compatible con CPUs antiguas o VMs con perfiles conservadores (por ejemplo, contenedores en algunos hosts cloud viejos). Un 10-20 por ciento más lenta pero arranca donde la otra crashea con illegal instruction.
  • polars-u64-idx: para DataFrames de más de 4 200 millones de filas. Es raro necesitarla; si te hace falta, ya lo sabrás.

Verifica la instalación abriendo un intérprete y comprobando la versión:

import polars as pl
print(pl.__version__)   # deberia imprimir 1.43.0 o superior en julio 2026
print(pl.show_versions())  # info detallada de features compiladas

Trampa común: instalar polars-lts-cpu junto con polars en el mismo entorno. Los dos paquetes exponen el mismo módulo polars y compiten; el importador acaba usando el último instalado. Elige uno y desinstala el otro.

Paso 2: Crear DataFrames desde diccionario, CSV y Parquet

La primera diferencia con Pandas la notas al crear el DataFrame. Polars distingue entre DataFrame (eager, en memoria, resultado final) y LazyFrame (plan de consulta, aún sin ejecutar). Empezamos con el modo eager, más familiar si vienes de Pandas.

import polars as pl

# Desde un diccionario Python
df = pl.DataFrame({
    "producto": ["silla", "mesa", "lampara", "estanteria"],
    "precio": [149.90, 299.00, 39.50, 129.00],
    "stock": [12, 3, 47, 8],
})
print(df)
# shape: (4, 3)
# ┌────────────┬────────┬───────┐
# │ producto   ┆ precio ┆ stock │
# │ ---        ┆ ---    ┆ ---   │
# │ str        ┆ f64    ┆ i64   │
# ╞════════════╪════════╪═══════╡
# │ silla      ┆ 149.9  ┆ 12    │
# │ mesa       ┆ 299.0  ┆ 3     │
# └────────────┴────────┴───────┘

# Desde CSV (eager, carga todo en RAM)
df_csv = pl.read_csv("ventas.csv")

# Desde Parquet (eager)
df_pq = pl.read_parquet("ventas.parquet")

# Desde Parquet con schema inferido y columnas seleccionadas
df_pq2 = pl.read_parquet("ventas.parquet", columns=["fecha", "importe", "cliente_id"])

El output de Polars es visual y explícito sobre los tipos: str, f64, i64. Nada de la ambigüedad de Pandas con object para strings y NaN para nulos indistinguibles de números faltantes. Polars tiene un tipo Null propio; el NaN flotante es distinto de un valor nulo, y esto evita muchos bugs.

Para datasets grandes, la variante lazy es la que quieres (la desarrollamos en el paso 4):

lf = pl.scan_parquet("ventas_grandes.parquet")   # devuelve LazyFrame, no lee nada aun
lf = pl.scan_csv("ventas_grandes.csv")

Trampa común: mezclar read_* con scan_* sin querer. read_parquet lee todo a memoria y devuelve un DataFrame; scan_parquet devuelve un LazyFrame sin leer nada. En pipelines grandes el segundo es el que salva la vida.

Paso 3: Selección, filtrado y agregación con expressions

Aquí llega el cambio conceptual más importante. Polars sustituye la API imperativa de Pandas por expressions: objetos que representan cómputos sobre columnas y que el motor puede optimizar y paralelizar. La sintaxis parece verbosa al principio pero se vuelve natural en un par de sesiones.

import polars as pl

df = pl.DataFrame({
    "categoria": ["A", "B", "A", "C", "B", "A", "C"],
    "importe": [120.0, 85.5, 200.0, 45.0, 175.0, 90.0, 60.0],
    "unidades": [3, 1, 5, 2, 4, 2, 1],
})

# Seleccionar columnas (equivalente a df[["col1", "col2"]] en Pandas)
df.select("categoria", "importe")

# Seleccionar con expression: nueva columna calculada
df.select(
    pl.col("categoria"),
    pl.col("importe"),
    (pl.col("importe") * pl.col("unidades")).alias("total"),
)

# Filtrar filas
df.filter(pl.col("importe") > 100)

# Filtro compuesto con operadores logicos
df.filter((pl.col("importe") > 100) & (pl.col("categoria") == "A"))

# Agregacion global (sobre todo el DataFrame)
df.select(
    pl.col("importe").sum().alias("importe_total"),
    pl.col("importe").mean().alias("importe_medio"),
    pl.col("unidades").max().alias("unidades_max"),
)

# Agregacion por grupo (group_by + agg)
df.group_by("categoria").agg(
    pl.col("importe").sum().alias("total_categoria"),
    pl.col("importe").mean().alias("media_categoria"),
    pl.len().alias("num_filas"),
).sort("categoria")

La regla mental es sencilla: dentro de select, with_columns, filter o agg, todo son expressions. pl.col("x") es la expression base que se refiere a una columna. Sobre ella encadenas métodos (.sum(), .mean(), .filter(), .str.contains(), .dt.year()). Todas las expressions son lazy internamente y se evalúan cuando llamas al método terminal (select, collect, write_parquet).

Trampa común: confundir with_columns con select. select devuelve un DataFrame con exactamente las columnas que le pasas; with_columns devuelve el original con las columnas nuevas añadidas o sobrescritas. Si haces df.select((pl.col("x") * 2).alias("y")) te queda un DataFrame de una sola columna, no lo que probablemente querías.

Paso 4: Lazy evaluation con scan_* y collect()

Diagrama del flujo lazy evaluation en Polars con scan_parquet, plan logico, optimizacion, plan fisico y collect final
Lazy evaluation: scan_parquet construye un plan, el optimizador aplica predicate y projection pushdown, y collect() ejecuta solo lo mínimo necesario.

La killer feature de Polars es la evaluación perezosa. En vez de ejecutar operación por operación, construyes un plan de consulta que Polars optimiza globalmente antes de tocar un solo byte del disco. Cuando trabajas con Parquet de decenas de gigabytes, esto es la diferencia entre pipeline en 5 segundos y pipeline en 5 minutos.

import polars as pl

# Construimos un plan sin ejecutar nada
plan = (
    pl.scan_parquet("ventas_2026_*.parquet")           # LazyFrame
    .filter(pl.col("fecha") >= "2026-06-01")            # empujado al escaneo
    .filter(pl.col("categoria").is_in(["A", "B"]))
    .group_by("categoria", "region")
    .agg(
        pl.col("importe").sum().alias("importe_total"),
        pl.col("cliente_id").n_unique().alias("clientes_unicos"),
    )
    .sort("importe_total", descending=True)
)

# Inspeccionar el plan optimizado (util para debug)
print(plan.explain(optimized=True))

# Ejecutar y materializar el resultado en un DataFrame
resultado = plan.collect()

# Alternativa: ejecutar en modo streaming para datasets que no caben en RAM
resultado = plan.collect(engine="streaming")

Lo que Polars hace por debajo es lo que hace un motor de bases de datos serio:

  • Predicate pushdown: los filtros por fecha y categoría se aplican durante el escaneo del Parquet, no después. Si un row group del fichero tiene fecha máxima anterior a junio 2026, Polars ni siquiera lo descomprime.
  • Projection pushdown: solo se leen del disco las columnas que aparecen en el plan (fecha, categoria, region, importe, cliente_id). El resto del Parquet ni se toca.
  • Common subexpression elimination: si usas la misma expression dos veces, se calcula una.
  • Slice pushdown: si terminas con .head(100), Polars deja de leer datos en cuanto tiene 100 filas.

La regla de oro: usa scan_* siempre que puedas y llama a collect() lo más tarde posible. Cuanto más largo el plan lógico, más optimizaciones puede hacer el motor. Si necesitas Pandas al final para pasárselo a scikit-learn, haz .collect().to_pandas() como último paso, no antes.

Trampa común: abusar de collect() intermedios "para inspeccionar". Cada collect rompe el plan y obliga al motor a materializar. Si necesitas ver algo, usa .head(10).collect() al final, o plan.explain() para leer el plan sin ejecutarlo.

Paso 5: Joins entre DataFrames (inner, left, outer, semi, anti)

Los joins son donde Polars saca más ventaja frente a Pandas. Un join hash sobre 10 millones de filas es entre 5 y 20 veces más rápido en Polars, y el uso de memoria es una fracción. La sintaxis es limpia y admite todos los tipos habituales:

import polars as pl

ventas = pl.DataFrame({
    "venta_id": [1, 2, 3, 4, 5],
    "cliente_id": [10, 20, 10, 30, 40],
    "importe": [100.0, 250.0, 75.0, 300.0, 150.0],
})

clientes = pl.DataFrame({
    "cliente_id": [10, 20, 30, 50],
    "nombre": ["Ana", "Bruno", "Carla", "Diego"],
    "pais": ["ES", "PT", "ES", "FR"],
})

# Inner join: solo filas con match en ambos lados
ventas.join(clientes, on="cliente_id", how="inner")

# Left join: todas las filas de ventas, columnas de clientes cuando hay match
ventas.join(clientes, on="cliente_id", how="left")

# Full outer join: todas las filas de ambos lados
ventas.join(clientes, on="cliente_id", how="full")

# Semi join: filas de ventas que TIENEN match en clientes (sin traer columnas)
ventas.join(clientes, on="cliente_id", how="semi")

# Anti join: filas de ventas que NO tienen match en clientes
ventas.join(clientes, on="cliente_id", how="anti")

# Join por columnas con nombres distintos
ventas.join(clientes, left_on="cliente_id", right_on="cliente_id", how="inner")

# Join con multiples claves
df_a.join(df_b, on=["fecha", "producto_id"], how="inner")

Los joins semi y anti merecen mención especial: en Pandas los tienes que emular con isin o merge + indicator, en Polars son ciudadanos de primera clase y se traducen a operaciones optimizadas por el motor. Un anti join es la forma canónica de responder "qué ventas son de clientes desconocidos".

Trampa común: hacer joins sobre columnas de tipo distinto. Polars es estricto: si ventas.cliente_id es i64 y clientes.cliente_id es i32, el join lanza error. Casteas con pl.col("cliente_id").cast(pl.Int64) antes del join.

Paso 6: Window functions y transformaciones por grupo

Las window functions permiten aplicar una agregación por grupo sin colapsar las filas: cada fila conserva su identidad pero recibe el valor agregado del grupo al que pertenece. Es lo que en SQL escribes con OVER (PARTITION BY ...). En Polars el operador es .over():

import polars as pl

df = pl.DataFrame({
    "departamento": ["Ventas", "Ventas", "IT", "IT", "IT", "Marketing"],
    "empleado": ["Ana", "Bruno", "Carla", "Diego", "Elena", "Fran"],
    "salario": [45000, 50000, 60000, 65000, 70000, 48000],
})

# Salario medio del departamento en cada fila (sin colapsar filas)
df.with_columns(
    pl.col("salario").mean().over("departamento").alias("salario_medio_dept"),
    pl.col("salario").max().over("departamento").alias("salario_max_dept"),
    (pl.col("salario") - pl.col("salario").mean().over("departamento")).alias("diff_vs_media"),
)

# Ranking dentro del grupo
df.with_columns(
    pl.col("salario").rank(method="dense", descending=True).over("departamento").alias("ranking_dept")
)

# Moving average temporal (rolling)
df_series = pl.DataFrame({
    "fecha": pl.date_range(pl.date(2026, 1, 1), pl.date(2026, 1, 10), interval="1d", eager=True),
    "ventas": [100, 120, 90, 150, 130, 110, 170, 160, 140, 180],
})
df_series.with_columns(
    pl.col("ventas").rolling_mean(window_size=3).alias("media_movil_3d")
)

Compara esto con Pandas, donde tienes que hacer df.groupby("departamento")["salario"].transform("mean") y aún así no puedes mezclar fácilmente varias agregaciones con operaciones sobre la columna original en una sola llamada. En Polars todo va dentro del with_columns, todo es una expression, todo se paraleliza.

Para series temporales Polars ofrece group_by_dynamic (agrupación por ventanas móviles temporales), rolling_* (rolling mean, sum, std sobre ventanas), y upsample/downsample. La cobertura es equivalente a Pandas pero muchísimo más rápida en series largas.

Trampa común: pensar que .over() ordena por el grupo. No lo hace; conserva el orden original de las filas. Si necesitas orden temporal dentro del grupo, encadena .sort_by("fecha").over("grupo").

Paso 7: Streaming para datasets que no caben en RAM

Diagrama del modo streaming de Polars procesando un dataset Parquet de 100 GB en batches con memoria acotada
El motor streaming de Polars procesa el dataset en batches sin cargarlo entero en memoria, con spill automático a disco cuando hace falta.

El modo streaming ejecuta el plan lazy por lotes en vez de cargar todo el DataFrame en memoria. Es lo que te permite procesar 100 GB de Parquet en un portátil con 16 GB de RAM. Activarlo es tan simple como pasar engine="streaming" a collect() o usar sink_parquet para escribir directamente el resultado sin materializar:

import polars as pl

plan = (
    pl.scan_parquet("logs_2026/*.parquet")           # supongamos 100 GB en total
    .filter(pl.col("nivel") == "ERROR")
    .group_by("servicio", "fecha")
    .agg(pl.len().alias("num_errores"))
    .sort("num_errores", descending=True)
)

# Opcion A: materializar en un DataFrame (usa streaming interno pero devuelve todo)
resultado = plan.collect(engine="streaming")

# Opcion B: escribir directamente a Parquet sin materializar en RAM
plan.sink_parquet("errores_agrupados.parquet")

# Opcion C: escribir a CSV para inspeccion humana
plan.sink_csv("errores_agrupados.csv")

# Opcion D: iterar batch a batch para procesar en Python (Polars 1.42+)
for batch in plan.collect_batches():
    procesar(batch)

El motor streaming de Polars 1.43 hace spill automático a disco cuando un hash join o un sort exceden la memoria configurada. No es tan robusto como DuckDB para queries SQL arbitrarias, pero para pipelines de ETL columnares es el estado del arte. Los operadores soportados en streaming crecen versión a versión; consulta la sección Streaming de la guía oficial para ver la matriz actual.

Cuándo usar streaming vs eager: si el dataset cabe holgado en RAM (menos de un tercio de la memoria disponible), eager es más rápido porque evita el overhead de particionar. Si roza el límite o lo supera, streaming es la única opción sensata.

Trampa común: operaciones no soportadas en streaming caen silenciosamente al modo eager y llenan la RAM. Antes de desplegar un pipeline crítico, ejecútalo sobre una muestra con plan.explain(streaming=True) y verifica que todos los nodos del plan tienen prefijo Streaming.

Paso 8: Interoperabilidad con Pandas para migración incremental

Casi nadie migra un proyecto grande a Polars de golpe. El patrón realista es ir moviendo por capas: primero los pipelines de ETL más pesados (donde el retorno es máximo), después el feature engineering, y dejar Pandas para la frontera con scikit-learn, statsmodels y las librerías de plot que aún esperan un pd.DataFrame.

import polars as pl
import pandas as pd

# De Polars a Pandas (usa Arrow, es rapido y con zero-copy cuando es posible)
df_pl = pl.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
df_pd = df_pl.to_pandas()                # copia clasica
df_pd_zc = df_pl.to_pandas(use_pyarrow_extension_array=True)  # zero-copy via Arrow

# De Pandas a Polars
df_pd = pd.DataFrame({"a": [10, 20, 30], "b": [1.1, 2.2, 3.3]})
df_pl2 = pl.from_pandas(df_pd)

# Compartir datos con DuckDB (via Arrow, zero-copy real)
import duckdb
duckdb.sql("SELECT * FROM df_pl WHERE a > 1").pl()   # devuelve Polars DataFrame

# Compartir con Apache Arrow directamente
import pyarrow as pa
arrow_table = df_pl.to_arrow()
df_pl3 = pl.from_arrow(arrow_table)

La regla práctica: Polars vive nativamente sobre Arrow, por lo que compartir datos con DuckDB, PyIceberg o cualquier motor Arrow-native es esencialmente gratis. Con Pandas, la conversión tiene un coste pero es rápida: 100 M de filas se pasan en segundos en un portátil moderno. Con use_pyarrow_extension_array=True, Pandas 2.x consume los buffers Arrow directamente sin copiar.

Polars 1.24 introdujo un reader nativo de Apache Iceberg que salta ficheros según estadísticas de partición y minimiza round-trips cuando el warehouse vive en S3. Pandas todavía necesita PyIceberg + conversión manual Arrow para lo mismo. Si tu equipo empieza a mirar seriamente lakehouses, esto es un argumento fuerte para pasarse a Polars.

Trampa común: convertir a Pandas demasiado pronto en el pipeline. Cada conversión rompe el plan lazy y materializa. Si vas a pasar por Pandas, hazlo lo más tarde posible y solo para lo que estrictamente lo necesita.

Paso 9: Benchmark práctico Pandas vs Polars sobre 1 millón de filas

Grafico de barras comparando tiempos de ejecucion Pandas 2.x vs Polars 1.43 en carga CSV, groupby, join y filtrado sobre 1 millon de filas
Benchmark reproducible sobre 1 millón de filas: Polars 1.43 bate a Pandas 2.x en las cuatro operaciones críticas, con diferencias entre 4x y 12x.

El código siguiente genera un dataset sintético de 1 millón de filas, ejecuta las cuatro operaciones más habituales (carga desde CSV, filtrado, group by, join) en Pandas y en Polars, y mide el tiempo con time.perf_counter. Ejecútalo en tu máquina para ver el diferencial real; los tiempos absolutos dependen de tu CPU y SSD pero las proporciones son estables.

import time
import numpy as np
import pandas as pd
import polars as pl

# Generar dataset sintetico de 1M filas
N = 1_000_000
rng = np.random.default_rng(seed=42)

df_pd = pd.DataFrame({
    "cliente_id": rng.integers(1, 10_000, N),
    "producto_id": rng.integers(1, 500, N),
    "importe": rng.uniform(10, 500, N),
    "fecha": pd.date_range("2026-01-01", periods=N, freq="min"),
    "categoria": rng.choice(["A", "B", "C", "D"], N),
})
df_pd.to_csv("bench.csv", index=False)
df_pd.to_parquet("bench.parquet")

# Tabla de productos para el join
productos_pd = pd.DataFrame({
    "producto_id": range(1, 501),
    "nombre_producto": [f"Producto {i}" for i in range(1, 501)],
    "coste": rng.uniform(5, 200, 500),
})
productos_pl = pl.from_pandas(productos_pd)

def cronometrar(fn):
    inicio = time.perf_counter()
    resultado = fn()
    return time.perf_counter() - inicio, resultado

# 1) Carga desde CSV
t_pd, _ = cronometrar(lambda: pd.read_csv("bench.csv"))
t_pl, _ = cronometrar(lambda: pl.read_csv("bench.csv"))
print(f"Load CSV     Pandas={t_pd:.2f}s   Polars={t_pl:.2f}s   speedup={t_pd/t_pl:.1f}x")

# 2) Filtrado
df_pd_loaded = pd.read_csv("bench.csv")
df_pl_loaded = pl.read_csv("bench.csv")
t_pd, _ = cronometrar(lambda: df_pd_loaded[df_pd_loaded["importe"] > 250])
t_pl, _ = cronometrar(lambda: df_pl_loaded.filter(pl.col("importe") > 250))
print(f"Filter       Pandas={t_pd:.3f}s  Polars={t_pl:.3f}s  speedup={t_pd/t_pl:.1f}x")

# 3) Group by + agregacion
t_pd, _ = cronometrar(lambda: df_pd_loaded.groupby("categoria")["importe"].agg(["sum", "mean", "count"]))
t_pl, _ = cronometrar(lambda: df_pl_loaded.group_by("categoria").agg(
    pl.col("importe").sum(), pl.col("importe").mean(), pl.len()
))
print(f"GroupBy      Pandas={t_pd:.3f}s  Polars={t_pl:.3f}s  speedup={t_pd/t_pl:.1f}x")

# 4) Join
t_pd, _ = cronometrar(lambda: df_pd_loaded.merge(productos_pd, on="producto_id", how="left"))
t_pl, _ = cronometrar(lambda: df_pl_loaded.join(productos_pl, on="producto_id", how="left"))
print(f"Join         Pandas={t_pd:.3f}s  Polars={t_pl:.3f}s  speedup={t_pd/t_pl:.1f}x")

# 5) Bonus: pipeline lazy completo con scan_parquet
t_pl_lazy, _ = cronometrar(lambda: (
    pl.scan_parquet("bench.parquet")
    .filter(pl.col("importe") > 250)
    .group_by("categoria")
    .agg(pl.col("importe").sum().alias("total"))
    .collect()
))
print(f"Lazy pipe    Polars lazy={t_pl_lazy:.3f}s (Parquet+filter+groupby)")

Los resultados típicos en un Ryzen 9 7950X con SSD NVMe (Julio 2026):

OperaciónPandas 2.xPolars 1.43Speedup
Carga CSV2.10 s0.28 s7.5x
Filtrado0.045 s0.011 s4.1x
GroupBy + agg0.180 s0.019 s9.5x
Join 1M x 5000.140 s0.012 s11.7x
Pipeline lazy Parquet0.024 s

El pipeline lazy sobre Parquet es la variante más interesante: gracias al predicate pushdown, Polars solo lee del disco las filas y columnas necesarias para el resultado, lo que en datasets grandes reduce el tiempo total en órdenes de magnitud frente al enfoque read all + filter in memory de Pandas.

Cuándo NO usar Polars (y quedarse con Pandas)

Diagrama del layout columnar Apache Arrow con chunks de datos comprimidos por columna que Polars usa como formato nativo en memoria
Polars vive sobre Apache Arrow: layout columnar en memoria con chunks contiguos que permiten SIMD, paralelismo y compartir datos zero-copy con DuckDB y PyIceberg.

Polars es maravilloso pero no es la respuesta universal. Estos son los cuatro escenarios donde Pandas sigue siendo la elección correcta en 2026:

  • Datasets pequeños (menos de 100 MB) o notebooks exploratorios rápidos. Con datos pequeños la diferencia de rendimiento es imperceptible y la familiaridad de Pandas vale más. Además, la salida rich en Jupyter de Pandas sigue siendo un poco más amigable para exploración visual rápida.
  • Pipelines fuertemente acoplados a scikit-learn, statsmodels, seaborn o Plotly Express. Todas esperan pd.DataFrame. Puedes convertir con .to_pandas() en la frontera, pero si el 90 por ciento del código habla con estas librerías, ganar 5x en el 10 por ciento restante no compensa la complejidad extra.
  • Equipos que no van a invertir en aprender expressions. Polars pide un cambio de mentalidad real. Si el equipo prefiere gastar más CPU antes que aprender un modelo nuevo, forzar la migración crea más fricción que valor.
  • Código legacy con miles de líneas Pandas idiomáticas. Migrar merece la pena solo si el pipeline es lento hoy. Si va bien, dejarlo en Pandas y meter Polars solo para los ETL nuevos es la decisión racional.

Para todo lo demás (pipelines de data engineering serios, feature engineering a escala, ETLs sobre Parquet e Iceberg, cualquier cosa que roce el límite de memoria) Polars es la elección obvia en 2026.

Recursos y libros recomendados para dominar Polars y Python data

Polars no vive aislada; forma parte del ecosistema Python de ciencia de datos junto a Pandas, NumPy, Apache Arrow, DuckDB y toda la stack de data engineering. Estos libros (enlaces afiliados Amazon con tag webmasteroson-21) son los que solemos recomendar en Arkaia para consolidar la base y saltar de Pandas a Polars con criterio:

Complementa con la documentación oficial (docs.pola.rs) que es de las mejores del ecosistema Python, y con los notebooks de ejemplo del repositorio pola-rs/polars en GitHub. Si vienes de Pandas, la sección Coming from Pandas de la guía oficial resume las diferencias conceptuales en dos páginas.

Para profundizar en el ecosistema data que rodea a Polars, revisa también nuestros tutoriales sobre construir agentes IA con LangGraph en Python (patrón parecido de expressions componibles) y sobre crear agentes con Claude API y MCP si quieres orquestar pipelines Polars desde un agente.

Preguntas frecuentes

¿Puedo reemplazar Pandas por Polars en un proyecto existente sin reescribir todo?

Sí, pero de forma incremental. El patrón realista: identifica el pipeline más lento (típicamente un ETL o un feature engineering pesado), reescríbelo en Polars, deja Pandas para la frontera con scikit-learn o los plots, y convierte con .to_pandas() justo antes de esa frontera. Migrar todo a Polars de golpe rara vez compensa; ir por capas sí.

¿Qué versión de Polars uso en 2026 y con qué Python?

Polars 1.43.0 (publicada el 21 de julio de 2026) es la estable actual y la que recomendamos. Requiere Python 3.10 como mínimo; usa 3.11 o 3.12 para aprovechar el speedup del intérprete. Si tu CPU es anterior a 2013 o corre en una VM cloud restrictiva y ves illegal instruction, instala polars-lts-cpu en vez de polars.

¿Polars puede procesar datasets que no caben en RAM?

Sí, con el motor streaming. Construye el plan con scan_parquet o scan_csv y ejecuta con plan.collect(engine="streaming") o directamente con plan.sink_parquet(...) para escribir el resultado sin materializar. Verifica con plan.explain(streaming=True) que todos los nodos del plan tienen prefijo Streaming; los operadores no soportados caen a modo eager silenciosamente.

¿Cómo se compara Polars con DuckDB para analítica sobre Parquet?

Son complementarias, no competidoras. DuckDB brilla como motor SQL sobre ficheros y para queries ad-hoc; Polars brilla para pipelines Python con transformaciones encadenadas y feature engineering. Muchos equipos en 2026 usan DuckDB para ingesta y limpieza SQL, Polars para el ETL en Python, y comparten datos zero-copy vía Apache Arrow. Puedes hacer duckdb.sql("SELECT ... FROM df_pl").pl() sin conversión real.

¿Los joins de Polars devuelven el mismo resultado que los de Pandas?

Semánticamente sí, pero cuidado con dos diferencias: Polars es más estricto con tipos (join sobre i64 vs i32 falla; hay que castear antes), y el manejo de nulos es diferente: en Polars, por defecto los nulos no hacen match entre sí en joins (comportamiento SQL estándar), mientras que Pandas puede tener sorpresas dependiendo del tipo. Testéalo siempre en un subconjunto antes de migrar joins críticos.

¿Polars soporta Apache Iceberg y lakehouses en 2026?

Sí, y cada vez mejor. Polars 1.24 introdujo un reader nativo de Iceberg que salta ficheros según estadísticas de partición y minimiza round-trips S3. Se integra con PyIceberg 0.12+ para escritura. Si tu equipo va hacia lakehouse (Iceberg, Delta Lake) esto es un argumento fuerte para adoptar Polars como motor de lectura y transformación en Python.

¿Cómo debug un pipeline Polars lento?

Tres herramientas. Primero, plan.explain(optimized=True) te muestra el plan que va a ejecutar (busca filtros no empujados al escaneo o joins con orden subóptimo). Segundo, plan.profile() ejecuta el plan y devuelve un DataFrame con el tiempo de cada nodo. Tercero, si el problema es memoria, activa engine="streaming" y comprueba con explain(streaming=True) que todos los nodos son streaming. Casi todos los problemas se detectan mirando el plan.

Conclusión

Polars 1.43 en julio de 2026 es la elección por defecto para pipelines Python de datos serios. La combinación de ejecución multihilo, layout columnar sobre Apache Arrow, evaluación perezosa con optimizador de consultas y API basada en expressions le da un diferencial de 5 a 15 veces sobre Pandas en las operaciones que realmente importan (joins, group bys, agregación) y capacidad de procesar datasets que no caben en RAM gracias al modo streaming. La curva de aprendizaje es real pero corta: en un par de sesiones ya escribes expressions con soltura.

El patrón ganador de 2026 no es "Polars en vez de Pandas" sino Polars como núcleo del ETL, Pandas en la frontera con scikit-learn y statsmodels, DuckDB para SQL sobre ficheros. Los tres motores comparten Apache Arrow como formato en memoria, por lo que interoperan sin coste real. Empieza reescribiendo tu pipeline más lento; si el speedup se nota (y con datasets sobre 500 MB casi seguro que sí), extiende el patrón al resto del proyecto. Si te has quedado con ganas de más, la documentación oficial de Polars y los benchmarks reproducibles del paso 9 son el mejor punto de partida para convencer a tu equipo con datos reales.

Este artículo contiene enlaces afiliados a Amazon con el tag webmasteroson-21. Si compras a través de estos enlaces, Arkaia puede recibir una comisión sin coste adicional para ti. Nuestra valoración editorial es independiente y no varía según los ingresos por afiliación.

Compartir:

Comentarios

Cargando comentarios...