03 · DuckDB y Polars¶
Cuaderno asociado
cuadernos/02_polars.ipynb. Volver al 00 - Índice de la demo.
Arrow es la llave maestra 🗝¶
DuckDB y Polars comparten el mismo modelo de memoria: Apache Arrow. La integración no serializa nada (ambos ven el mismo buffer columnar).
flowchart LR
PL["polars.DataFrame<br/>LazyFrame"]
AR["Arrow<br/>(buffers columnares)"]
DU["DuckDB"]
PL -->|"1 · sin copia"| AR
AR -->|"2 · escaneo vectorizado"| DU
DU -->|"3 · .pl()"| AR
AR -->|"4 · cero copias"| PL
subgraph sql["Lo que aporta cada uno"]
direction TB
S1["DuckDB: SQL complejo,<br/>joins grandes, ficheros,<br/>pushdown a Parquet/S3"]
S2["Polars: expresiones<br/>encadenadas, control fino,<br/>streaming en Rust"]
end
Polars → DuckDB¶
DuckDB ve las variables Polars del scope del programa por su nombre:
import duckdb, polars as pl
df = pl.DataFrame({"ciudad": ["Madrid", "Lima", "Madrid"], "ventas": [10, 5, 7]})
duckdb.sql("SELECT ciudad, sum(ventas) AS v FROM df GROUP BY 1 ORDER BY v DESC").show()
Pushdown dentro de un LazyFrame
Si se pasa un LazyFrame, DuckDB empuja al plan de ejecución de Polars las selecciones de columna y los filtros que sabe traducir. Polars solo materializa lo necesario. Con un DataFrame normal esto no sucede.
Con registro explícito equivalente (útil cuando el nombre no es representativo):
DuckDB → Polars¶
El resultado de una relación (duckdb.sql(...), con.sql(...)) se convierte según corresponda:
rel = con.sql("SELECT * FROM ventas WHERE importe > 100")
df = rel.pl() # DataFrame
lz = rel.pl(lazy=True) # LazyFrame
Cambio de la 2.0
con.execute(q).pl() ya no existe. execute() devuelve un objeto de resultados, no una relación. La regla:
- Relación (
con.sql) →.pl(),.to_arrow_table(),.to_arrow_reader() - Resultado (
con.execute) →.fetchall(),.fetchone(),.fetchdf(),.df()
Ojo: fetchone()/fetchmany() fueron eliminados de las relaciones.
Patrón híbrido que más se usa¶
# 1. Polars para preparar y limpiar en memoria
lz = (pl.scan_parquet("datos/ventas/*.parquet")
.filter(pl.col("importe") > 0)
.with_columns(pl.col("fecha").str.to_date()))
# 2. DuckDB para el join SQL
con.register("limpio", lz)
resultado = con.sql("""
SELECT l.producto, sum(l.importe) AS total, count(DISTINCT l.cliente) AS clientes
FROM limpio l
JOIN dim_producto p USING (producto_id)
WHERE p.activo
GROUP BY ALL
ORDER BY total DESC
""").pl()
Inserción por lotes: con.append¶
La 2.0 añade append para cargar lotes sin ir fila a fila:
con.execute("CREATE TABLE IF NOT EXISTS medidas (i INTEGER, s VARCHAR)")
con.append("medidas", df_pandas) # acepta pandas.DataFrame
Importante
con.append() acepta pandas.DataFrame. Con Polars, el camino fiable es con.register(...) + INSERT INTO ... SELECT * FROM ... O convertir con .to_pandas(). La clase duckdb.Appender ya no se instancia directamente.
¿Cuándo usar qué?¶
| Tarea | Mejor opción | Por qué |
|---|---|---|
Limpieza de columnas, with_columns |
Polars | Expresiones explícitas y perezosas, muy rápidas |
JOIN de 10 M × 10 M con desbordamiento a disco |
DuckDB | Spilling y optimizador con estadísticas |
| Leer 500 ficheros Parquet particionados | DuckDB | read_parquet con hive partitioning y pruning |
| Gráficos rápidos tras agregación | Polars → Plotly | Interoperabilidad directa |
| Lógica recursiva o SQL heredado | DuckDB | CTE recursivas, dialecto completo |
Ejercicios¶
- Crear un
polars.DataFramede 3 filas y consúltalo desde DuckDB sin usarregister. - Producir un
LazyFramedesde una consulta DuckDB y ver suexplain(). - Comprobar que
con.execute(q).pllanzaAttributeErrory escribe la alternativa correcta. - Con un
LazyFramede 1 M de filas, compara el tiempo decon.registerfrente adf.collect()previo. ¿Por qué difiere?
Soluciones en cuadernos/99_soluciones.ipynb.