Saltar a contenido

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):

con.register("ventas_pl", df)      # o con.register("x", df.to_arrow())

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

  1. Crear un polars.DataFrame de 3 filas y consúltalo desde DuckDB sin usar register.
  2. Producir un LazyFrame desde una consulta DuckDB y ver su explain().
  3. Comprobar que con.execute(q).pl lanza AttributeError y escribe la alternativa correcta.
  4. Con un LazyFrame de 1 M de filas, compara el tiempo de con.register frente a df.collect() previo. ¿Por qué difiere?

Soluciones en cuadernos/99_soluciones.ipynb.