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:
- Un formato eficiente para guardarlos y leerlos rápido.
- Un motor de transformaciones pensado para escala.
- 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 que60es 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:
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:
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.
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:
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 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):
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¶
- 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.
- 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.
- 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(), elfetchall()o eldf().
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:
- 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.
- 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:
- 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.
- 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(...)necesitapl.col(no acepta el nombre de columna como cadena en.aggde la misma forma que Pandas). - Creer que DuckDB es un servidor: es una biblioteca embebida en Python.
- No instalar
pyarrowy queto_parquet/read_parquetfallen. - 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 unLazyFramesin 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 esdf.select("columna")odf.select(pl.col("columna")). - Olvidar
.collect()en modo lazy y quedarte con un plan sin ejecutar (el «resultado» es unLazyFrame, no una tabla). - Escribir el Parquet con
index=Truede 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):
- Guárdalo en Parquet y compara el tamaño con el CSV; explica el resultado.
- Con Polars, calcula el importe medio por región y el máximo por categoría.
- Con DuckDB, consulta el Parquet para obtener los 3 productos (o categorías) con más ingresos.
- Repite la misma agregación con Pandas y comprueba que los tres resultados coinciden.
- Escribe en una frase cuándo usarías cada herramienta.
- Crea una segunda tabla (clientes) y haz un JOIN con Polars: total gastado por nombre de cliente, ordenado de mayor a menor.
- Repite el JOIN del ejercicio 6 con DuckDB, consultando el Parquet y el DataFrame a la vez. ¿Cuál te resultó más natural?
- Añade una columna condicional
tamanoconwhen/then(grande si el importe supera 200, pequeño si no) y cuenta cuántos hay de cada tipo. - Ejecuta
.explain()sobre un flujo lazy con filtro y selección, y explica en una frase qué significanPROJECT n/m COLUMNSySELECTION. - 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:
- Los ejercicios 1–10 anteriores, cada uno con su celda de código y su interpretación en Markdown.
- 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).
- 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¶
- ¿De dónde leen los datos que estoy comparando? (memoria o archivo)
- ¿Qué optimiza cada herramienta y en qué escenario conviene cada una?
- ¿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.