Saltar a contenido

1.7 · Parquet, Polars y DuckDB

Objetivos. Al terminar este capítulo podrás: explicar qué es el formato Parquet y por qué se usa para datos grandes; hacer transformaciones con Polars usando expresiones; y consultar archivos Parquet/CSV con DuckDB desde Python, sabiendo cuándo elegir cada herramienta.

Evidencia de logro. Guardarás una tabla en Parquet, la agregarás con Polars y la consultarás con DuckDB, comparando los tres caminos (Pandas, Polars, SQL) sobre el mismo dato.

Contexto y motivación

Hasta ahora hemos trabajado con tablas que caben en memoria. Cuando los datos crecen (millones de filas, gigabytes), aparecen tres necesidades:

  1. Un formato eficiente para guardarlos y leerlos rápido.
  2. Un motor de transformaciones pensado para escala.
  3. Una forma de consultar archivos sin cargarlos enteros en memoria.

La combinación moderna que responde a las tres es Parquet + Polars + DuckDB:

  • Parquet es el formato de archivo (columnar y comprimido).
  • Polars es la alternativa rápida a Pandas (expresiones y modo lazy).
  • DuckDB es un motor SQL embebido que consulta Parquet/CSV directamente.

No hace falta memorizar tres APIs a fondo: la idea es saber que existen, para qué sirve cada una y cuándo conviene salir de Pandas. Es la "caja de herramientas grande" para cuando la caja habitual se queda corta.

Dependencias

Estos paquetes (pyarrow, polars, duckdb) ya están declarados en el pyproject.toml del curso y se instalan con uv sync. No hace falta ningún servidor: los tres son bibliotecas locales que viven dentro de tu proceso de Python.

Vocabulario

Término Significado
Columnar Guardar los datos por columnas, no por filas
Parquet Formato columnar, comprimido y con tipos
Expresión En Polars, operación declarada sobre columnas (pl.col(...))
Lazy Modo que no ejecuta hasta que se pide el resultado (collect)
Eager Modo inmediato: cada operación ejecuta al momento (el normal)
Embebido Motor que vive dentro del proceso Python, sin servidor aparte
Proyección Leer solo las columnas necesarias (PROJECT 3/4 COLUMNS)
Predicado pushdown Aplicar el filtro al leer, no después (SELECTION en el plan)
Vectorizado Procesar bloques de valores a la vez, no valor a valor
scan_parquet El punto de entrada lazy de Polars para archivos Parquet
Zero-copy DuckDB consulta un DataFrame de Pandas sin copiarlo

Prerrequisitos

Haber trabajado 1.5 · Pandas y 1.6 · SQL. Este capítulo compara y amplía esas dos herramientas.

Orden de los ejemplos

El mini-caso reutiliza la tabla ventas y el fichero Parquet creados en los apartados anteriores. Ejecuta los bloques en orden o repite esa preparación si pruebas uno de forma aislada.

1. Parquet: el formato columnar

Para entender Parquet, compara cómo se guarda un CSV y cómo se guarda un Parquet:

  • En un CSV, los datos van por filas y sin tipo (todo es texto): 2026-09-01,audio,norte,60. Para saber que 60 es un número, hay que adivinarlo al leer.
  • En Parquet, los datos van por columnas, comprimidos y con tipos: cada columna guarda su tipo (fecha, str, float64…) y al leer llega ya correcta.

¿Por qué importa "por columnas"? Porque la mayoría de consultas solo tocan unas pocas columnas. En un formato por columnas, el motor lee solo las columnas que necesita y se salta el resto; en un CSV tiene que leer todas las filas enteras. Con muchos datos, esa diferencia es enorme.

import pandas as pd

ventas = pd.DataFrame({
    "fecha": ["2026-09-01", "2026-09-01", "2026-09-02", "2026-09-02", "2026-09-03"],
    "categoria": ["audio", "pantalla", "audio", "periferico", "pantalla"],
    "region": ["norte", "norte", "sur", "sur", "norte"],
    "importe": [60.0, 300.0, 45.0, 80.0, 250.0],
})

ventas.to_parquet("ventas.parquet", index=False)   # escribir
leido = pd.read_parquet("ventas.parquet")          # leer
print(leido)
print(leido.dtypes)

Salida esperada:

        fecha   categoria region  importe
0  2026-09-01       audio  norte     60.0
1  2026-09-01    pantalla  norte    300.0
2  2026-09-02       audio    sur     45.0
3  2026-09-02  periferico    sur     80.0
4  2026-09-03    pantalla  norte    250.0
fecha            str
categoria        str
region           str
importe      float64
dtype: object

1.1 La comprobación decisiva: 100 000 filas

El aviso anterior es honesto con 5 filas. Veamos qué pasa cuando el volumen es el que Parquet espera:

import os
import pandas as pd

grande = pd.DataFrame({
    "id_pedido": range(100_000),
    "id_cliente": [i % 118 for i in range(100_000)],
    "categoria": ["audio", "pantalla", "periferico", "accesorio"] * 25_000,
    "importe": [round(20 + (i * 37) % 480, 2) for i in range(100_000)],
})
# lineterminator="\n" fija el salto de línea: sin él, Windows escribe "\r\n"
# y el mismo dato ocupa ≈100 KB más, por el byte extra de cada fila.
grande.to_csv("grande.csv", index=False, lineterminator="\n")
grande.to_parquet("grande.parquet", index=False)
print(f"CSV:     {os.path.getsize('grande.csv') / 1024:.0f} KB")
print(f"Parquet: {os.path.getsize('grande.parquet') / 1024:.0f} KB")

Salida esperada:

CSV:     2128 KB
Parquet: 613 KB

3,5 veces menos con el mismo dato. La compresión columnar explota la repetición (las categorías se repiten 25 000 veces cada una) y los tipos declarados (un float64 ocupa 8 bytes fijos; en CSV cada número es texto de longitud variable). Esta es la razón real por la que Parquet es el estándar de la industria: menos disco, menos red, menos memoria al leer.

Parquet no siempre pesa menos

Con estas 5 filas, el Parquet puede ocupar más que un CSV: guarda metadatos y el esquema, que pesan más que el dato. El tamaño exacto depende de la versión del motor, las opciones y el sistema de archivos. La ventaja de Parquet aparece con muchas filas y datos repetidos/numéricos, donde la compresión columnar reduce el tamaño y acelera la lectura. No uses Parquet "porque sí"; úsalo cuando el volumen lo justifique. Es un buen ejemplo de que no hay herramienta mágica: cada formato tiene su caso de uso.

2. Polars: transformaciones con expresiones

Polars se parece a Pandas pero usa expresiones sobre columnas y encadena operaciones. La idea clave: en lugar de decir "coge la columna, ahora filtra, ahora agrupa…", declaras qué quieres sobre cada columna con pl.col(...).

Su salida por pantalla muestra el tipo de cada columna, lo que ayuda a detectar errores de un vistazo:

import polars as pl

df = pl.DataFrame(ventas)
print(df)

Salida esperada:

shape: (5, 4)
┌────────────┬────────────┬────────┬─────────┐
│ fecha      ┆ categoria  ┆ region ┆ importe │
│ ---        ┆ ---        ┆ ---    ┆ ---     │
│ str        ┆ str        ┆ str    ┆ f64     │
╞════════════╪════════════╪════════╪═════════╡
│ 2026-09-01 ┆ audio      ┆ norte  ┆ 60.0    │
│ 2026-09-01 ┆ pantalla   ┆ norte  ┆ 300.0   │
│ 2026-09-02 ┆ audio      ┆ sur    ┆ 45.0    │
│ 2026-09-02 ┆ periferico ┆ sur    ┆ 80.0    │
│ 2026-09-03 ┆ pantalla   ┆ norte  ┆ 250.0   │
└────────────┴────────────┴────────┴─────────┘

shape: (5, 4) = 5 filas y 4 columnas; la segunda línea de la cabecera (str, f64) son los tipos: f64 = float64, str = texto.

2.1 Filtrar y agrupar con pl.col

print(df.filter(pl.col("importe") > 100))
print(df.group_by("categoria")
    .agg(pl.col("importe").sum().alias("total"))
    .sort("categoria"))

Salida esperada:

shape: (2, 4)
┌────────────┬───────────┬────────┬─────────┐
│ fecha      ┆ categoria ┆ region ┆ importe │
│ ---        ┆ ---       ┆ ---    ┆ ---     │
│ str        ┆ str       ┆ str    ┆ f64     │
╞════════════╪═══════════╪════════╪═════════╡
│ 2026-09-01 ┆ pantalla  ┆ norte  ┆ 300.0   │
│ 2026-09-03 ┆ pantalla  ┆ norte  ┆ 250.0   │
└────────────┴───────────┴────────┴─────────┘
shape: (3, 2)
┌────────────┬───────┐
│ categoria  ┆ total │
│ ---        ┆ ---   │
│ str        ┆ f64   │
╞════════════╪═══════╡
│ audio      ┆ 105.0 │
│ pantalla   ┆ 550.0 │
│ periferico ┆ 80.0  │
└────────────┴───────┘

pl.col("importe") es una expresión: no es "la columna" todavía, sino "lo que quiero hacer con la columna". Por eso puedes encadenarle métodos (.sum(), .mean(), .alias("total") para renombrar).

El encadenado es la forma idiomática de Polars:

resumen = (df
           .filter(pl.col("importe") >= 50)
           .group_by("region")
           .agg(pl.col("importe").mean().round(1).alias("importe_medio"))
           .sort("importe_medio", descending=True))
print(resumen)

Salida esperada:

shape: (2, 2)
┌────────┬───────────────┐
│ region ┆ importe_medio │
│ ---    ┆ ---           │
│ str    ┆ f64           │
╞════════╪═══════════════╡
│ norte  ┆ 203.3         │
│ sur    ┆ 80.0          │
└────────┴───────────────┘

Lee la cadena como una frase: "filtra importes ≥ 50, agrupa por región, calcula la media redondeada del importe y ordénala de mayor a menor".

2.2 JOIN: combinar tablas como en SQL

El merge de Pandas tiene aquí su equivalente con el mismo vocabulario que aprendiste en 1.6:

clientes = pl.DataFrame({
    "id_cliente": [1, 2, 3],
    "nombre": ["Ana", "Luis", "Marta"],
    "segmento": ["VIP", "normal", "normal"],
})
pedidos = pl.DataFrame({
    "id_cliente": [1, 1, 2, 3],
    "importe": [120.0, 80.0, 300.0, 45.0],
})

resumen = (pedidos
           .group_by("id_cliente")
           .agg(pl.col("importe").sum().alias("total"))
           .join(clientes, on="id_cliente")
           .select("nombre", "segmento", "total")
           .sort("total", descending=True))
print(resumen)

Salida esperada:

shape: (3, 3)
┌────────┬──────────┬───────┐
│ nombre ┆ segmento ┆ total │
│ ---    ┆ ---      ┆ ---   │
│ str    ┆ str      ┆ f64   │
╞════════╪══════════╪═══════╡
│ Luis   ┆ normal   ┆ 300.0 │
│ Ana    ┆ VIP      ┆ 200.0 │
│ Marta  ┆ normal   ┆ 45.0  │
└────────┴──────────┴───────┘

El patrón es el de SQL: agregar, unir por la clave, seleccionar columnas y ordenar — pero encadenado como una tubería. Fíjate en .select(...): el JOIN trae todas las columnas de ambas tablas y el select se queda con las que interesan (el equivalente al SELECT nombre, segmento, total).

2.3 when/then: columnas condicionales

El equivalente de CASE WHEN de SQL, para crear una columna según una condición:

df_productos = pl.DataFrame({
    "producto": ["raton", "monitor", "teclado"],
    "importe": [60.0, 300.0, 80.0],
})
clasificado = df_productos.with_columns(
    pl.when(pl.col("importe") > 200)
      .then(pl.lit("grande"))
      .otherwise(pl.lit("pequeño"))
      .alias("tamano")
)
print(clasificado)

Salida esperada:

shape: (3, 3)
┌──────────┬─────────┬─────────┐
│ producto ┆ importe ┆ tamano  │
│ ---      ┆ ---     ┆ ---     │
│ str      ┆ f64     ┆ str     │
╞══════════╪═════════╪═════════╡
│ raton    ┆ 60.0    ┆ pequeño │
│ monitor  ┆ 300.0   ┆ grande  │
│ teclado  ┆ 80.0    ┆ pequeño │
└──────────┴─────────┴─────────┘

with_columns añade una columna (no reemplaza el DataFrame); pl.lit("grande") crea un valor literal para rellenar la columna. La estructura when/then/otherwise se lee como el CASE WHEN ... THEN ... ELSE de SQL — el mismo concepto con otra sintaxis.

2.4 Modo lazy (ampliación)

perezoso = df.lazy().filter(pl.col("importe") > 100)  # no ejecuta aún
print(perezoso.collect())                              # ejecuta al pedirlo

Salida esperada:

shape: (2, 4)
┌────────────┬───────────┬────────┬─────────┐
│ fecha      ┆ categoria ┆ region ┆ importe │
│ ---        ┆ ---       ┆ ---    ┆ ---     │
│ str        ┆ str       ┆ str    ┆ f64     │
╞════════════╪═══════════╪════════╪═════════╡
│ 2026-09-01 ┆ pantalla  ┆ norte  ┆ 300.0   │
│ 2026-09-03 ┆ pantalla  ┆ norte  ┆ 250.0   │
└────────────┴───────────┴────────┴─────────┘

En modo eager (el normal), cada método ejecuta al momento. En modo lazy, los métodos solo acumulan un plan y nada se ejecuta hasta .collect(). Ventaja: Polars puede ver el plan completo y optimizarlo (reordenar filtros, descartar columnas innecesarias). Brilla con archivos grandes y muchas operaciones encadenadas.

El punto de entrada lazy para archivos es scan_parquet (o scan_csv): no lee el archivo, declara que lo leerá. El método .explain() muestra el plan que Polars ha optimizado:

perezoso = (
    pl.scan_parquet("grande.parquet")
      .filter(pl.col("categoria") == "audio")
      .select("id_pedido", "importe")
)
print(perezoso.explain())

Salida esperada:

simple π 2/2 ["id_pedido", "importe"]
  Parquet SCAN [grande.parquet]
  PROJECT 3/4 COLUMNS
  SELECTION: col("categoria") == "audio"
  ESTIMATED ROWS: 100000

Lee el plan: PROJECT 3/4 COLUMNS significa que solo leerá 3 de las 4 columnas del archivo (la proyección: se salta la que no necesitas); la SELECTION es el filtro aplicado al leer (el predicado pushdown: no lee todo y filtra después, filtra mientras lee). Estas dos optimizaciones son las que hacen que el modo lazy brille con archivos grandes — y son la misma idea que Parquet columnar habilita.

print(perezoso.collect().head(3))

Salida esperada:

shape: (3, 2)
┌───────────┬─────────┐
│ id_pedido ┆ importe │
│ ---       ┆ ---     │
│ i64       ┆ i64     │
╞═══════════╪═════════╡
│ 0         ┆ 20      │
│ 4         ┆ 168     │
│ 8         ┆ 316     │
└───────────┴─────────┘

3. DuckDB: SQL sobre archivos

DuckDB ejecuta SQL directamente sobre un Parquet o CSV, sin cargarlo en un DataFrame:

import duckdb

consulta = duckdb.sql("""
    SELECT categoria, SUM(importe) AS total
    FROM 'ventas.parquet'
    GROUP BY categoria
    ORDER BY total DESC
""")
print(consulta)

Salida esperada:

┌────────────┬────────┐
│ categoria  │ total  │
│  varchar   │ double │
├────────────┼────────┤
│ pantalla   │  550.0 │
│ audio      │  105.0 │
│ periferico │   80.0 │
└────────────┴────────┘

La gracia está en el FROM 'ventas.parquet': DuckDB lee el archivo directamente, sin que lo importes antes. Puedes tratar un archivo en disco como si fuera una tabla de una base de datos. También funciona con 'ventas.csv'.

Y como es embebido, no hay servidor que instalar ni arrancar: la base de datos es la propia biblioteca dentro de tu proceso Python.

3.1 DuckDB también consulta tus DataFrames

No hace falta que los datos estén en un archivo: DuckDB puede consultar un DataFrame de Pandas que ya tengas en memoria, sin copiarlo (zero-copy):

import pandas as pd
import duckdb

ventas = pd.DataFrame({
    "categoria": ["audio", "pantalla", "audio", "periferico", "pantalla"],
    "importe": [60.0, 300.0, 45.0, 80.0, 250.0],
})
r = duckdb.sql("SELECT categoria, COUNT(*) AS n FROM ventas GROUP BY categoria ORDER BY n DESC, categoria")
print(r)

Salida esperada:

┌────────────┬───────┐
│ categoria  │   n   │
│  varchar   │ int64 │
├────────────┼───────┤
│ audio      │     2 │
│ pantalla   │     2 │
│ periferico │     1 │
└────────────┴───────┘

El nombre ventas en el FROM es el DataFrame de Pandas. Fíjate en el ORDER BY n DESC, categoria: audio y pantalla empatan con 2 filas, y sin el segundo criterio el motor puede devolverlas en cualquier orden, de modo que la salida cambiaría entre ejecuciones. Cuando el orden importe, desempata siempre. Este puente es muy práctico: puedes seguir en Pandas y usar SQL para el paso que más te interese (una agregación compleja, un JOIN), sin migrar todo el flujo.

3.2 JOIN entre archivos: Parquet y CSV juntos

Y la combinación que más se usa en proyectos reales: unir fuentes distintas en una sola consulta SQL, sin cargar ninguna entera:

import pandas as pd
import duckdb

clientes = pd.DataFrame({
    "id_cliente": [1, 2, 3],
    "nombre": ["Ana", "Luis", "Marta"],
})
pedidos_peq = pd.DataFrame({
    "id_cliente": [1, 1, 2, 3],
    "importe": [120.0, 80.0, 300.0, 45.0],
})
pedidos_peq.to_parquet("pedidos.parquet", index=False)

r = duckdb.sql("""
    SELECT c.nombre, SUM(p.importe) AS total
    FROM 'pedidos.parquet' p
    JOIN clientes c ON c.id_cliente = p.id_cliente
    GROUP BY c.nombre ORDER BY total DESC
""")
print(r)

Salida esperada:

┌─────────┬────────┐
│ nombre  │ total  │
│ varchar │ double │
├─────────┼────────┤
│ Luis    │  300.0 │
│ Ana     │  200.0 │
│ Marta   │   45.0 │
└─────────┴────────┘

En una sola consulta: el Parquet de pedidos y el DataFrame de clientes se unen por la clave, como en 1.6, pero cada fuente es la que le conviene (archivo para lo grande, memoria para lo pequeño).

El resultado se convierte a Pandas o Polars en una línea:

df_pandas = consulta.df()
df_polars = consulta.pl()
print("Pandas:", type(df_pandas).__name__)
print("Polars:", type(df_polars).__name__)

Salida esperada:

Pandas: DataFrame
Polars: DataFrame

4. Mini-caso: tres caminos, un mismo resultado

Para "importe total por categoría", las tres herramientas dan lo mismo:

# Pandas (ya lo conoces)
print(ventas.groupby("categoria")["importe"].sum().sort_values(ascending=False))

# Polars (expresiones)
print(df.group_by("categoria")
        .agg(pl.col("importe").sum().alias("total"))
        .sort("total", descending=True))

# DuckDB (SQL sobre el archivo)
print(duckdb.sql("""
    SELECT categoria, SUM(importe) AS total
    FROM 'ventas.parquet' GROUP BY categoria ORDER BY total DESC
"""))

Salida esperada:

categoria
pantalla      550.0
audio         105.0
periferico     80.0
Name: importe, dtype: float64
shape: (3, 2)
┌────────────┬───────┐
│ categoria  ┆ total │
│ ---        ┆ ---   │
│ str        ┆ f64   │
╞════════════╪═══════╡
│ pantalla   ┆ 550.0 │
│ audio      ┆ 105.0 │
│ periferico ┆ 80.0  │
└────────────┴───────┘
┌────────────┬────────┐
│ categoria  │ total  │
│  varchar   │ double │
├────────────┼────────┤
│ pantalla   │  550.0 │
│ audio      │  105.0 │
│ periferico │   80.0 │
└────────────┴────────┘

Mismo dato, mismo resultado, tres estilos. No hay "una correcta": depende del contexto.

5. ¿Cuál es más rápida? La comparación honesta

Es la pregunta que todos hacemos, y la respuesta honesta es: depende del formato, del tamaño y de si los datos ya están en memoria. Medimos la misma agregación (suma de importe por categoría) en tres escenarios:

duckdb.sql(...) no ejecuta la consulta todavía

Igual que el modo lazy de Polars, duckdb.sql(...) devuelve una relación: una consulta preparada que se ejecuta cuando pides el resultado (al imprimirla, con .fetchall(), .df() o .pl()). Si mides el tiempo de duckdb.sql(...) sin pedir el resultado, solo mides cuánto tarda en preparar la consulta, y DuckDB parecerá milagrosamente rápido. En las mediciones siguientes usamos .fetchall() para forzar la ejecución.

Escenario A: 100 000 filas ya cargadas en un DataFrame de Pandas:

import time
import pandas as pd
import polars as pl
import duckdb

grande_pd = pd.read_csv("grande.csv")

t0 = time.perf_counter()
r_pandas = grande_pd.groupby("categoria")["importe"].sum()
t1 = time.perf_counter()
print(f"Pandas:  {(t1 - t0) * 1000:6.1f} ms")

t0 = time.perf_counter()
r_polars = (pl.scan_csv("grande.csv")
            .group_by("categoria")
            .agg(pl.col("importe").sum())
            .collect())
t1 = time.perf_counter()
print(f"Polars:  {(t1 - t0) * 1000:6.1f} ms (lazy, desde CSV)")

t0 = time.perf_counter()
r_duck = duckdb.sql(
    "SELECT categoria, SUM(importe) AS importe FROM 'grande.csv' GROUP BY categoria"
).fetchall()                                  # fetchall() fuerza la ejecución
t1 = time.perf_counter()
print(f"DuckDB:  {(t1 - t0) * 1000:6.1f} ms (SQL, desde CSV)")

Salida esperada (orientativa; los tiempos exactos dependen de tu máquina):

Pandas:     4.1 ms
Polars:    12.1 ms (lazy, desde CSV)
DuckDB:    61.5 ms (SQL, desde CSV)

Pandas gana, y no es un error: los datos ya estaban en su memoria; Polars y DuckDB pagan la lectura del CSV que Pandas no necesita. Es la lección más importante de esta comparación: no compares motores sin comparar también de dónde leen.

Escenario B: la misma agregación leyendo el Parquet (1 000 000 de filas):

import numpy as np

rng = np.random.default_rng(42)
millon = pd.DataFrame({
    "id_cliente": rng.integers(0, 118, 1_000_000),
    "categoria": rng.choice(["audio", "pantalla", "periferico", "accesorio"], 1_000_000),
    "importe": rng.uniform(20, 500, 1_000_000).round(2),
})
millon.to_parquet("millon.parquet", index=False)

t0 = time.perf_counter()
r1 = pd.read_parquet("millon.parquet").groupby("categoria")["importe"].sum()
t1 = time.perf_counter()
print(f"Pandas (leer + agregar):  {(t1 - t0) * 1000:6.1f} ms")

t0 = time.perf_counter()
r2 = (pl.scan_parquet("millon.parquet")
      .group_by("categoria")
      .agg(pl.col("importe").sum())
      .collect())
t1 = time.perf_counter()
print(f"Polars (lazy parquet):    {(t1 - t0) * 1000:6.1f} ms")

t0 = time.perf_counter()
r3 = duckdb.sql(
    "SELECT categoria, SUM(importe) AS importe FROM 'millon.parquet' GROUP BY categoria"
).fetchall()
t1 = time.perf_counter()
print(f"DuckDB (SQL parquet):     {(t1 - t0) * 1000:6.1f} ms")

Salida esperada (orientativa):

Pandas (leer + agregar):   87.4 ms
Polars (lazy parquet):     55.3 ms
DuckDB (SQL parquet):      37.4 ms

Aquí el orden cambia: Polars y DuckDB superan a Pandas. Los dos leen el Parquet columnar con motores vectorizados y paralelos, cargan solo las dos columnas que necesita la agregación y no construyen un DataFrame completo. Pandas, en cambio, lee el millón de filas entero a memoria antes de agregar.

Entre Polars y DuckDB la diferencia es pequeña y cambia de una ejecución a otra: repitiendo la medición, en esta máquina ambos rondan los 30 ms, y Pandas, los 90–110 ms. No te fíes de una cifra suelta: los tiempos dependen de tu equipo, de la versión y de lo que esté haciendo el sistema. Lo que sí se mantiene es la conclusión de fondo: leyendo Parquet, un motor columnar es varias veces más rápido que cargar todo en Pandas.

5.1 Las tres lecciones de la medición

  1. El formato importa tanto como el motor. El mismo Pandas pasa de unos pocos milisegundos a cerca de 100 solo por tener que leer el archivo: si los datos están en memoria, Pandas es difícil de batir; si hay que leerlos, el formato manda.
  2. Para consultar archivos, Polars y DuckDB juegan en la misma liga. Elige DuckDB si piensas la pregunta en SQL («dame esta agregación de este Parquet») y Polars si prefieres encadenar expresiones en Python.
  3. Mide lo que crees que mides. Un motor perezoso (Polars lazy, las relaciones de DuckDB) no hace el trabajo hasta que pides el resultado: cronometra siempre hasta el collect(), el fetchall() o el df().

Cómo medir tú mismo

Usa time.perf_counter() como en los ejemplos, repite la medición unas cuantas veces (la primera incluye arranques de la biblioteca) y desconfía de cualquier benchmark que no diga de dónde leen los datos. La mayoría de las tablas «Polars es 30× más rápido» comparan operaciones distintas o condiciones distintas.

5.2 ¿Cuál elegir? La tabla de decisión ampliada

Situación Herramienta Por qué
Ya estás en Pandas y la tabla cabe en memoria Pandas No pagues el coste de cambiar; los datos ya están en su memoria
Muchas transformaciones encadenadas sobre datos grandes Polars Las expresiones + el plan lazy optimizan la cadena completa
Consultar archivos Parquet/CSV con SQL sin cargarlos DuckDB El motor vectorizado lee solo lo necesario; la forma más breve de escribirlo
Unir un Parquet grande con datos en memoria DuckDB FROM 'archivo.parquet' JOIN dataframe en una consulta
Integrar con el resto de scikit-learn Pandas Todo el ecosistema del curso habla Pandas (o NumPy)
Guardar datos a escala o compartirlos entre equipos Parquet Tipos declarados, compresión, lectura por columnas
Explorar un archivo que no conoces DuckDB SELECT * FROM 'x.parquet' LIMIT 10 sin importar nada
El flujo del curso (las tablas de TechShop) Pandas El volumen no lo justifica; simplicidad primero

Las dos reglas que resumen la tabla:

  1. La simplicidad manda: si Pandas te basta, Pandas está bien. Cambiar de herramienta tiene un coste de aprendizaje y de integración que hay que justificar.
  2. El cuello de botella decide: si el problema es leer (archivos grandes), Parquet + DuckDB; si es transformar (cadenas largas), Polars; si es integrar (scikit-learn, gráficos), Pandas.

6. Caso de estudio: el archivo que no cabía en memoria

Imagina que TechShop te entrega pedidos_2026.parquet con 50 millones de filas (unos 2 GB descomprimidos) y te pide el total por región. Tu portátil tiene 16 GB de RAM: cargarlo con Pandas es posible pero lento e incómodo; cada iteración del análisis relee todo.

El flujo profesional con las herramientas de este capítulo. Para que el ejemplo sea ejecutable, simulamos el archivo con 2 millones de filas (en el caso real serían 50 millones, pero el flujo es idéntico):

import duckdb
import numpy as np
import pandas as pd

rng = np.random.default_rng(7)
pedidos_2026 = pd.DataFrame({
    "region": rng.choice(["norte", "sur", "este", "oeste"], 2_000_000),
    "importe": rng.uniform(20, 500, 2_000_000).round(2),
})
pedidos_2026.to_parquet("pedidos_2026.parquet", index=False)

r = duckdb.sql("""
    SELECT region, COUNT(*) AS pedidos, ROUND(SUM(importe), 0) AS total
    FROM 'pedidos_2026.parquet'
    GROUP BY region
    ORDER BY total DESC
""")
print(r)

Efecto observable: la consulta devuelve una tabla de cuatro filas (una por región, con su número de pedidos y su total) en una fracción de segundo. No se muestra una salida exacta porque los totales dependen de los datos simulados; compruébalo tú: deben salir cuatro regiones y unos 500 000 pedidos en cada una. Con el archivo real, DuckDB tampoco cargaría los 2 GB en memoria: lee solo las dos columnas que la consulta necesita (region e importe) gracias al formato columnar. Si el resultado te vale para el informe, has terminado; si necesitas explorar más, lo vuelcas con .df() y sigues en Pandas con un DataFrame pequeño.

Y si mañana el análisis crece (filtros por fecha, joins con clientes, ventanas temporales), el mismo archivo Parquet sirve para Polars lazy o para consultas SQL más complejas — sin cambiar el formato de los datos. Esa es la ventaja de haber elegido bien el formato: las herramientas van y vienen, los datos se quedan.

Profundización: por qué el formato columnar comprime tan bien

La intuición detrás de la compresión de Parquet, en un minuto: en una columna de categoría con 25 000 filas y 4 valores distintos, guardar audio, pantalla, audio, accesorio… como texto repetido es un desperdicio. Los formatos columnares aplican dos trucos:

  1. Codificación por diccionario: en lugar de repetir el texto, guardan una tabla con los 4 valores distintos y una lista de índices (0, 1, 0, 2, …). La lista de índices es mucho más pequeña que el texto repetido.
  2. Compresión generalista (snappy, zstd) sobre los bloques ya codificados, que explota la regularidad que queda.

Por eso el tamaño depende del contenido: una columna de importes con muchos decimales distintos comprime menos que una de categorías repetidas; y por eso Parquet no siempre gana al CSV con tablas pequeñas y sin repetición. No es magia: es estadística sobre la estructura de los datos.

Errores frecuentes

  • Usar Parquet para 5 filas y sorprenderse de que pese más que el CSV.
  • En Polars, olvidar que group_by(...).agg(...) necesita pl.col (no acepta el nombre de columna como cadena en .agg de la misma forma que Pandas).
  • Creer que DuckDB es un servidor: es una biblioteca embebida en Python.
  • No instalar pyarrow y que to_parquet/read_parquet fallen.
  • Pensar que hay que sustituir Pandas por Polars "porque es más moderno": si Pandas te basta, Pandas está bien.
  • Comparar tiempos sin controlar de dónde leen los datos (en memoria frente a archivo): la comparación deja de tener sentido.
  • Cronometrar duckdb.sql(...) o un LazyFrame sin pedir el resultado: solo mides la preparación de la consulta, no su ejecución.
  • Creer el primer tiempo medido: la primera ejecución incluye el arranque de la biblioteca; repite la medición varias veces.
  • En Polars, usar df["columna"] como en Pandas para seleccionar: la forma idiomática es df.select("columna") o df.select(pl.col("columna")).
  • Olvidar .collect() en modo lazy y quedarte con un plan sin ejecutar (el «resultado» es un LazyFrame, no una tabla).
  • Escribir el Parquet con index=True de Pandas y arrastrar una columna índice inútil a todos los flujos siguientes.
  • Usar duckdb.sql(...) sin recoger el resultado y esperar que haya modificado algo: las consultas de lectura no mutan nada.

Práctica de transferencia

Con el dataset que has creado en este capítulo (o generando uno más grande con 10 000 filas simuladas):

  1. Guárdalo en Parquet y compara el tamaño con el CSV; explica el resultado.
  2. Con Polars, calcula el importe medio por región y el máximo por categoría.
  3. Con DuckDB, consulta el Parquet para obtener los 3 productos (o categorías) con más ingresos.
  4. Repite la misma agregación con Pandas y comprueba que los tres resultados coinciden.
  5. Escribe en una frase cuándo usarías cada herramienta.
  6. Crea una segunda tabla (clientes) y haz un JOIN con Polars: total gastado por nombre de cliente, ordenado de mayor a menor.
  7. Repite el JOIN del ejercicio 6 con DuckDB, consultando el Parquet y el DataFrame a la vez. ¿Cuál te resultó más natural?
  8. Añade una columna condicional tamano con when/then (grande si el importe supera 200, pequeño si no) y cuenta cuántos hay de cada tipo.
  9. Ejecuta .explain() sobre un flujo lazy con filtro y selección, y explica en una frase qué significan PROJECT n/m COLUMNS y SELECTION.
  10. Mide con time.perf_counter() la misma agregación desde CSV y desde Parquet con Pandas. ¿Cuánto aporta el formato?

Producto evaluable

Cuaderno 05_datos_a_escala.py con:

  1. Los ejercicios 1–10 anteriores, cada uno con su celda de código y su interpretación en Markdown.
  2. Una celda final que genere un dataset de 100 000 filas simuladas, lo guarde en Parquet y mida el tiempo de una agregación en Pandas frente a Polars y DuckDB (comenta lo que observes).
  3. Una tabla final de decisión: para cada una de las tres herramientas, una situación concreta de tu proyecto donde la usarías y por qué.

Formato de entrega: cuaderno marimo ejecutado, con conclusiones al final.

Resumen y referencia rápida

Tarea Cómo
Guardar/leer Parquet df.to_parquet(...) / pd.read_parquet(...) (requiere pyarrow)
DataFrame Polars pl.DataFrame(...)
Expresiones Polars pl.col("col"), .filter(...), .group_by(...).agg(...)
JOIN en Polars .join(otro, on="clave") + .select(...)
Condicional en Polars pl.when(cond).then(valor).otherwise(otro)
Lazy Polars .lazy()....collect() (o scan_parquet(...)...collect())
Ver el plan optimizado flujo.explain()
SQL sobre archivos duckdb.sql("SELECT ... FROM 'archivo.parquet'")
SQL sobre un DataFrame duckdb.sql("SELECT ... FROM mi_dataframe") (zero-copy)
A Pandas/Polars consulta.df() / consulta.pl()
Medir tiempos time.perf_counter() antes y después, varias repeticiones
Elegir Pandas (habitual) · Polars (transformar a escala) · DuckDB (consultar archivos)

Las tres preguntas de control

  1. ¿De dónde leen los datos que estoy comparando? (memoria o archivo)
  2. ¿Qué optimiza cada herramienta y en qué escenario conviene cada una?
  3. ¿El formato que elegí seguirá siendo útil cuando el análisis crezca?

Fin de la Unidad 1. Con esto dominas la capa de datos.

Siguiente: Unidad 2 · Preparación de datos, donde limpiarás, visualizarás y transformarás esos datos para dejarlos listos para modelar. Vuelve a la introducción de la unidad para repasar el índice y los productos evaluables.