Capítulo 9. polars, Arrow y datos a gran escala

▶ Ejecutar este capítulo en Binder

La primera vez que se abre, Binder construye el entorno en la nube (unos 10-20 min); verás una pantalla de progreso. Después queda en caché y abre en segundos. Si parece que no responde, espera a que termine de construirse o vuelve a intentarlo.

Pocas incorporaciones al ecosistema de la ciencia de datos han tenido en años el efecto de polars. Lo inició Ritchie Vink en junio de 2020 como un experimento en Rust; alcanzó la versión 1.0 el 1 de julio de 2024 y llega a 2026, en su línea 1.42, convertido en una elección de primera fila (Polars developers 2026). No es «un pandas más rápido»: es un motor de consultas con forma de DataFrame. En su modo perezoso (lazy) las operaciones no se ejecutan una a una, sino que se acumulan en un plan de consulta (query plan) que un optimizador reordena y poda antes de tocar los datos, y la ejecución paraleliza por defecto sobre todos los núcleos. Con ello ataca a la vez los dos frentes que dejamos abiertos al medir pandas en el cap. 8 (§8.6): el tiempo y la memoria.

Este capítulo presenta polars y DuckDB —una base de datos analítica incrustada o embebida (embedded)—, y el contraste exige conocer bien pandas: no partimos de cero, sino del modelo de datos, los grupos y las combinaciones de tablas que estudiamos a fondo en el cap. 8. Empezaremos por el suelo que ambos pisan (§9.1): por qué el procesamiento columnar (column-oriented) exprime el procesador, qué es Apache Arrow y cómo permite que una tabla viaje entre bibliotecas sin copia (zero-copy), el puente que dejamos prometido al cerrar el capítulo anterior. Después, el protagonista (§9.2): expresiones, contextos y el modelo perezoso. DuckDB pondrá sobre la mesa el vocabulario SQL analítico —SELECT, GROUP BY, JOIN directamente sobre Parquet— que anunciamos en su día (§9.4). Cerraremos con los datos que desbordan la RAM —ejecución en flujo (streaming) y particionado—, un criterio honesto para elegir herramienta y un caso integrador de principio a fin.

El dato de trabajo sigue siendo el catálogo de música (maharshipandya 2022). Del cap. 5 heredamos data/processed/musica.parquet —el Spotify Tracks Dataset ya limpio: 113 999 pistas, 20 rasgos de audio y cero ausentes—, que el cap. 8 analizó con pandas. Aquí lo abriremos primero por dentro con Arrow y, en cuanto tengamos el modelo perezoso, lo consultaremos entero: es el mismo catálogo, cabe holgado en memoria, pero la maquinaria que aplicaremos es la que usaríamos con datos que no cupieran. Nota de versión: esta edición se ha verificado con polars 1.42.1, pyarrow 22.0.0 y duckdb 1.5.4 sobre CPython 3.11.

Del array a la columna: Arrow, el sustrato común

Antes de escribir la primera consulta en polars conviene entender qué comparten polars, DuckDB y el pandas moderno, porque esa pieza común explica tanto su velocidad como su interoperabilidad. Esta sección recupera el argumento columnar del cap. 5 y lo traslada del disco al procesador (§9.1.1), abre el formato Arrow con pyarrow (§9.1.2) y comprueba, midiendo direcciones de memoria, que las tres bibliotecas pueden compartir los mismos bytes (§9.1.3).

Por qué columnar

En el cap. 5 justificamos el almacenamiento columnar en disco (§5.5.1): leer solo las columnas que la consulta necesita y comprimir mejor lo homogéneo. Aquel argumento hablaba de entrada/salida; hay un segundo, igual de importante, que habla del procesador. Una columna contigua en memoria se recorre al ritmo de la caché: cada línea que sube trae 64 bytes útiles —dieciséis float32 consecutivos—, el hardware anticipa las siguientes, y las instrucciones SIMD —una instrucción, varios datos— que ya explotaba la vectorización de NumPy (§7.2) operan sobre varios valores por ciclo. En una disposición por filas, en cambio, los valores de una misma columna llegan intercalados con el resto de los campos, y la caché arrastra bytes que nadie va a mirar.

Las bases de datos llegaron primero a esta conclusión. El proyecto MonetDB/X100 mostró que la ejecución clásica, que interpreta la consulta tupla a tupla, deja el procesador ocioso, y propuso evaluar cada operador sobre lotes de valores —vectores dimensionados para caber en la caché— (Boncz et al. 2005); el modelo columnar analítico que nació de esa línea está sistematizado en (Abadi et al. 2013), y polars y DuckDB son herederos directos de ese diseño. La distinción clásica ayuda a situarse (Kleppmann 2017): los sistemas transaccionales (OLTP) tocan pocas filas enteras muchas veces por segundo —dar de alta un pedido, actualizar un saldo— y agradecen la disposición por filas; los analíticos (OLAP) recorren millones de filas pero pocas columnas para agregarlas, y agradecen la columnar. La ciencia de datos vive casi siempre en el segundo régimen: de ahí que todo el capítulo sea columnar.

El formato Arrow

Apache Arrow es una especificación de cómo disponer datos tabulares en memoria —columnar e independiente del lenguaje— acompañada de implementaciones para C++, Rust, Java o Python; es proyecto de primer nivel de la Fundación Apache desde febrero de 2016 (Apache Software Foundation 2026a). Lo presentamos en el cap. 5 como el análogo en memoria de Parquet (§5.5.2), y la pareja se reparte el trabajo: Parquet comprime y codifica por bloques para durar en el disco (Apache Software Foundation 2026b); Arrow mantiene los valores descomprimidos, contiguos y alineados, listos para el procesador. Pasar de uno a otro es, precisamente, (de)serializar. La representación de una columna es de una sencillez deliberada: un buffer contiguo con los valores, un mapa de bits de validez con un bit por valor —uno presente, cero nulo— y, para columnas categóricas, un diccionario: los valores se guardan como índices enteros y las categorías, una sola vez. Abramos con pyarrow la tabla de música, quedándonos con cinco columnas para no listar las veinte y pidiendo que track_genre vuelva como diccionario, que es como Parquet ya la guarda en disco:

import pyarrow.parquet as pq

tabla = pq.read_table("data/processed/musica.parquet",
                      columns=["track_genre", "popularity",
                               "energy", "tempo", "explicit"],
                      read_dictionary=["track_genre"])
print(tabla.schema)
# track_genre: dictionary<values=string, indices=int32, ordered=0>
# popularity: int64
# energy: double
# tempo: double
# explicit: bool
print(tabla.num_rows, tabla.nbytes)     # 113999 3250234

energia = tabla.column("energy")
print(energia.null_count, energia.nbytes)   # 0 926242
print(tabla.column("track_genre").chunk(0).dictionary[:3])
# [
#   "acoustic",
#   "afrobeat",
#   "alt-rock"
# ]

El esquema es exactamente el que declaró nuestro pipeline (§5.10): los tipos sobrevivieron intactos al viaje por el disco. Y la aritmética de los bytes es transparente: la columna energy ocupa 926 242 bytes, que son 911 992 de valores (8 bytes por cada una de las 113 999 pistas float64) más 14 250 del mapa de validez (un bit por pista). Este catálogo llega sin ausentes —null_count vale 0, todos los bits del mapa a uno—, y aun así Arrow reserva el mapa: cuando hay ausentes son ceros en él, no centinelas incrustados entre los valores, de modo que cualquier tipo puede ser nulo sin cambiar de dtype, una idea que pandas solo alcanzó con el backend PyArrow (§8.3.3) y que en polars veremos como norma. La columna track_genre, por su parte, es un diccionario: 113 999 índices int32 más los 114 nombres de género guardados una sola vez, la misma economía que logramos con la categoría de pandas en el cap. 8. Las cinco columnas caben así en 3 250 234 bytes.

Los puentes: el mismo dato sin copiar

Un formato común solo vale si las bibliotecas pueden compartirlo sin traducirlo. Para eso Arrow define la interfaz C de datos (C Data Interface): un puñado de estructuras en C con las que una biblioteca entrega a otra los punteros a sus buffers; los datos no se mueven, cambia quién los mira. En Python el mecanismo se envuelve en el protocolo PyCapsule: todo objeto que expone __arrow_c_stream__ puede ser consumido por cualquier otra biblioteca del ecosistema, y en 2026 lo exponen pyarrow, polars, DuckDB y pandas. Este es el puente que prometimos al cerrar el cap. 8, donde vimos el backend PyArrow de pandas (§8.3.3) con sus cadenas eficientes —las que pandas 3 adopta por defecto (Van den Bossche 2024), con PyArrow recomendado pero no obligatorio (Roeschke y Hoefler 2023)—. Comprobemos que el puente es literal, siguiendo con la tabla del listado anterior:

import pyarrow as pa
import polars as pl
import pandas as pd

df_pl = pl.DataFrame(tabla)      # Arrow -> polars, sin copiar
print(df_pl.shape)                          # (113999, 5)
print(hasattr(df_pl, "__arrow_c_stream__")) # True

vuelta = pa.table(df_pl)         # polars -> Arrow, via PyCapsule
antes = tabla.column("energy").chunk(0).buffers()[1].address
ahora = vuelta.column("energy").chunk(0).buffers()[1].address
print(antes == ahora)            # True: el MISMO buffer

df_pd = tabla.to_pandas(types_mapper=pd.ArrowDtype)
print(df_pd["energy"].dtype)     # double[pyarrow]

pl.DataFrame acepta la tabla de pyarrow y la envuelve sin copiarla; pa.table hace el viaje inverso consumiendo la PyCapsule de polars. La prueba de que nada se ha duplicado está en la comparación central: la dirección de memoria del buffer de energy es la misma antes y después del viaje de ida y vuelta —son los mismos bytes con dos dueños—. Y to_pandas con types_mapper=pd.ArrowDtype produce un DataFrame de pandas cuyas columnas siguen respaldadas por los buffers de Arrow, como delata el dtype double[pyarrow] del cap. 8.

Figura 9.1. El sustrato Arrow. Arrow como sustrato común: pandas (con backend PyArrow), polars y DuckDB comparten la misma memoria columnar sin copiar nada; solo el tránsito hacia y desde Parquet, el formato de disco, exige (de)serializar.

La figura 9.1 resume la geometría del capítulo: en el centro, la memoria Arrow; alrededor, tres bibliotecas que la intercambian sin copia; a un lado, Parquet, el único tránsito con coste de (de)serialización. polars implementa el formato de forma nativa en Rust —sus columnas son arrays de Arrow— y DuckDB, un motor columnar diseñado para el análisis incrustado (Raasveldt y Mühleisen 2019), lo consume y lo produce directamente. A explotar ese sustrato dedicamos las dos próximas secciones: primero el motor de consultas de polars (§9.2) y después el SQL analítico de DuckDB (§9.4).

polars: un DataFrame para el multinúcleo

El capítulo anterior cerró con una promesa (§8.6): una herramienta que atacara a la vez los dos frentes del rendimiento —tiempo y memoria— con un plan de consulta optimizado. polars es esa herramienta, y su cimiento es el formato Arrow que acabamos de presentar (§9.1.2): cada columna de un DataFrame de polars es un array Arrow. En esta sección conoceremos su diseño y su pieza más original —el lenguaje de expresiones— en modo ansioso o inmediato (eager), en el que cada operación se evalúa en el acto; en la §9.3 veremos que el mismo lenguaje, sin cambiar una letra, funciona también en modo perezoso (lazy).

El diseño

polars nació en junio de 2020 como proyecto personal de Ritchie Vink, un ingeniero neerlandés que lo escribió en Rust, un lenguaje compilado sin recolector de basura que permite paralelismo seguro y un control fino de la memoria (Vink 2021). La versión 1.0 llegó el 1 de julio de 2024 y en 2026 la serie estable es la 1.42.x —este libro usa la 1.42.1 (Polars developers 2026)—. Más que una reimplementación rápida de pandas, polars es una revisión de sus decisiones de diseño; conviene tener claras las cuatro que más notará quien venga del cap. 8:

  • No hay índice de filas. Donde pandas alineaba todas las operaciones por las etiquetas del índice (§8.1), polars declara en su guía de migración que cada fila se identifica solo por su posición (Polars developers 2026): desaparecen loc/iloc, las reindexaciones y las ambigüedades que el índice arrastraba.

  • Tipos estrictos. Cada columna tiene un tipo fijo y las conversiones se piden con cast; no hay promociones silenciosas como las que vimos al perfilar memoria en la §7.1.2.

  • Un único ausente: null. Vale para todos los tipos —enteros, fechas, cadenas, booleanos— gracias al mapa de bits de validez de Arrow (§9.1.2). Donde el pandas clásico convertía en float64 un entero con ausentes, aquí la columna sigue siendo entera; NaN es solo un flotante más, no una marca de ausencia.

  • Paralelismo por defecto. NumPy y pandas ejecutan casi todas sus operaciones en un solo hilo (Harris et al. 2020), que la vectorización del cap. 7 (§7.2) exprime; polars reparte entre los núcleos las operaciones sobre columnas distintas sin que el usuario configure nada.

La distinción entre null y NaN es tan importante —y tan distinta del convenio de pandas— que merece comprobarse en el intérprete:

import polars as pl

s = pl.Series("energy", [0.75, None, float("nan")])
print(s.dtype)                # Float64
print(s.is_null().to_list())  # [False, True, False]
print(s.is_nan().to_list())   # [False, None, True]

enteros = pl.Series("popularity", [40, None, 80])
print(enteros.dtype, enteros.null_count())  # Int64 1

Para is_null, el NaN de la tercera posición es un flotante perfectamente válido; para is_nan, el null de la segunda no es ni verdadero ni falso: es desconocido, y el ausente se propaga, como la lógica de tres valores que veremos reaparecer cuando hablemos SQL con DuckDB más adelante en este capítulo. Y la serie entera con un ausente sigue siendo Int64, sin conversión alguna.

Nuestro primer DataFrame no puede ser otro que el catálogo de música que dejó preparado el pipeline del cap. 5 (§5.10), el mismo fichero que pandas leyó en la §8.1: mismo dato, otra herramienta. polars imprime sus tablas enmarcadas con caracteres de caja; en los listados reproducimos esa salida simplificada, con las mismas filas y cabeceras pero sin el marco (y proyectando cinco columnas de las veinte para no desbordar el margen):

musica = pl.read_parquet("data/processed/musica.parquet")
print(musica.shape)   # (113999, 20)
print(musica.select("track_name", "track_genre",
                    "popularity", "energy", "tempo").head(3))
# shape: (3, 5)
# track_name        track_genre  popularity  energy  tempo
# str               str          i64         f64     f64
# Comedy            acoustic     73          0.461   87.917
# Ghost - Acoustic  acoustic     55          0.166   77.489
# To Begin Again    acoustic     57          0.359   76.332

Quien compare con la salida de head del cap. 8 notará que falta la columna de índice a la izquierda —no existe— y que el tipo de cada columna aparece en la propia cabecera (str, str, i64, f64, f64): en un sistema de tipos estricto, el esquema es parte de la tabla. Las cadenas de track_name y track_genre viajan como str de primera clase, sin el dtype object que pandas arrastraba, y los rasgos de audio en float64, la precisión que fijamos al perfilar tipos en la §7.1.2.

El lenguaje de expresiones

La pieza central de la interfaz de polars no es ningún método del DataFrame, sino un objeto aparentemente humilde: la expresión. pl.col("energy") no extrae ninguna columna; nombra una columna llamada energy, de cualquier tabla que la tenga, y a partir de ese nombre podemos describir una transformación completa encadenando métodos:

media = pl.col("energy").mean().round(3).alias("media")
print(type(media))
# <class 'polars.expr.expr.Expr'>
print(media)
# col("energy").mean().round().alias("media")

No se ha calculado nada: no hay dato alguno al que aplicar la media. El objeto Expr es la descripción de un cálculo —tómese la columna energy, promédiese, redondéese, llámese media al resultado—, como una receta que aún no ha pasado por la cocina; su representación impresa (que abrevia los argumentos, como el 3 de round) es literalmente esa descripción (Janssens y Nieuwdorp 2025). Las expresiones se componen con los operadores habituales de Python, que también quedan sin evaluar:

doble = (pl.col("energy") * 2).alias("doble")
es_energetica = pl.col("energy") > 0.8
print(es_energetica)
# [(col("energy")) > (dyn float: 0.8)]

Aritmética, comparaciones, agregaciones, manipulación de fechas o cadenas: todo el vocabulario que en pandas se repartía entre métodos, funciones de NumPy y accesores como .str o .dt vive aquí dentro de la expresión. ¿Y cuándo se ejecuta por fin la receta? Cuando un contexto la evalúa.

Los contextos

Un contexto es un método del DataFrame que recibe expresiones, las evalúa contra sus datos y decide qué forma tiene el resultado. Los cuatro que cubren casi todo el trabajo son select, with_columns, filter y group_by().agg(). El primero, select, devuelve solo las columnas que describen sus expresiones:

print(musica.select(
    pl.col("energy").mean().round(3).alias("media"),
    (pl.col("tempo") == 0).sum().alias("rotas"),
))
# shape: (1, 2)
# media  rotas
# f64    u32
# 0.641  157

Reaparecen las 157 pistas con tempo igual a cero —un tempo de 0 BPM es un imposible físico, la lectura rota que el cap. 10 tratará como ausente—, aquí contadas con una comparación booleana; la media de energy sale directa, sin cast, porque el rasgo ya es float64. Y como las dos expresiones son independientes, polars las evalúa en paralelo: es la forma que toma el paralelismo por defecto dentro de cualquier contexto, sin hilos ni configuración por nuestra parte.

El segundo contexto, with_columns, evalúa las mismas expresiones pero añade las columnas resultantes a la tabla (o las sustituye, si el nombre ya existe), conservando todo lo demás:

con_extra = musica.with_columns(
    (pl.col("energy") > 0.8).alias("es_energetica"),
    (pl.col("duration_ms") / 1000).round(1).alias("dur_s"),
)
print(con_extra.shape)         # (113999, 22)
print(con_extra.columns[-2:])  # ['es_energetica', 'dur_s']

El tercero, filter, espera expresiones booleanas y conserva las filas donde valen True, el papel que en pandas hacían las máscaras de la §8.2.3; varias condiciones separadas por comas se combinan como una conjunción:

habladas = musica.filter(pl.col("speechiness") > 0.96)
print(habladas.shape)                             # (11, 20)
print(habladas["track_genre"].unique().to_list()) # ['comedy']

Solo 11 pistas superan una locuacidad (speechiness) de 0,96, y todas son de comedia: son grabaciones habladas —monólogos, sketches—, no música, y el rasgo que mide la proporción de palabra las delata sin excepción. Y el cuarto contexto, group_by().agg(), es el dividir-aplicar-combinar de la §8.4 con las agregaciones descritas como expresiones; agrupar por género daría 114 filas, así que nos ceñimos al puñado de seis de firma reconocible:

seis = ["classical", "hip-hop", "jazz", "pop", "reggaeton", "rock"]
resumen = (musica.filter(pl.col("track_genre").is_in(seis))
           .group_by("track_genre").agg(
               pl.col("energy").mean().round(3).alias("media"),
               pl.col("energy").count().alias("n"),
               pl.col("energy").max().round(3).alias("pico"),
           ).sort("track_genre"))
print(resumen)
# shape: (6, 4)
# track_genre  media  n     pico
# str          f64    u32   f64
# classical    0.19   1000  0.953
# hip-hop      0.683  1000  0.994
# jazz         0.353  1000  0.938
# pop          0.606  1000  0.986
# reggaeton    0.739  1000  0.969
# rock         0.679  1000  0.99

Las cifras cuadran con las que obtuvimos con pandas en la §8.4: cada género tiene su firma de energy, de 0,19 en classical —música acústica— a 0,739 en reggaeton, sobre 1 000 pistas por género —count, como en pandas, ignoraría cualquier null, aunque aquí no haya—. Un detalle: group_by no garantiza el orden de los grupos (el paralelismo tiene ese precio), de ahí el sort final.

La consecuencia más elegante del diseño es que la misma expresión significa cosas distintas según el contexto que la evalúe:

media = pl.col("energy").mean().round(3)
print(musica.select(media).item())                     # 0.641
print(musica.group_by("track_genre").agg(media).shape) # (114, 2)
print(musica.with_columns(media.alias("g")).shape)     # (113999, 21)

En select, la media colapsa la tabla a una fila; en group_by().agg() produce un valor por grupo; en with_columns, donde el resultado debe casar con las 113 999 filas originales, el agregado se difunde a todas ellas, al estilo del broadcasting que conocimos en NumPy (§7.2). La figura 9.2 resume esta idea, que conviene fijar antes de seguir: expresiones que describen, contextos que evalúan.

Figura 9.2. Una expresión, tres contextos. Una misma expresión —la media de la columna energy— evaluada por tres contextos distintos: select la colapsa a una fila, with_columns la difunde a las 113 999 filas originales y group_by().agg() la calcula por grupo.

Todo lo anterior ha ocurrido en modo inmediato: cada contexto se ha evaluado al instante, como haría pandas. La verdadera recompensa de separar descripción y cálculo llegará en la §9.3: si las expresiones no se ejecutan hasta que un contexto las reclama, nada impide acumular contextos enteros y dejar que polars optimice el plan completo antes de tocar un byte.

El modo perezoso: planes de consulta y optimizador

Todo el polars que hemos escrito hasta aquí trabaja como NumPy y pandas (caps. 7 y 8): cada expresión se evalúa en cuanto se escribe y devuelve su resultado. Es el modo inmediato o ansioso (eager), y no es lo que hace especial a esta biblioteca. Su seña de identidad es el modo perezoso (lazy): las operaciones no se ejecutan al declararse, sino que se anotan en un plan de consulta (query plan) que un optimizador revisa, poda y reordena antes de ejecutarlo todo de una vez. El cap. 8 se despidió (§8.6) prometiendo que polars atacaba los dos frentes del rendimiento —el tiempo y la memoria— con un plan de consulta optimizado; esta sección cumple esa promesa.

Del DataFrame al LazyFrame

El modo perezoso tiene dos puertas de entrada. La discreta: cualquier DataFrame se convierte en su versión perezosa con .lazy(), útil cuando los datos ya están en memoria. Y la importante: empezar perezoso desde el propio fichero con pl.``scan_parquet o pl.``scan_csv, gemelas de las funciones read_* cuyo prefijo es toda una declaración de intenciones: scan_ no carga nada. Devuelve un LazyFrame que solo sabe dónde está el fichero y qué esquema tiene; a Parquet le basta para ello el bloque de metadatos del pie del fichero que estudiamos en el cap. 5. Repitamos en perezoso el resumen por género que el modo inmediato nos dio en la §9.2.3, sobre el mismo musica.parquet que dejó el pipeline del cap. 5 (§5.10):

import polars as pl

seis = ["classical", "hip-hop", "jazz", "pop", "reggaeton", "rock"]
resumen = (
    pl.scan_parquet("data/processed/musica.parquet")
    .filter(pl.col("track_genre").is_in(seis))
    .group_by("track_genre")
    .agg(
        media=pl.col("energy").mean(),
        n=pl.len(),
        pico=pl.col("energy").max(),
    )
    .with_columns(pl.col("media", "pico").round(3))
    .sort("track_genre")
)
print(type(resumen))
# <class 'polars.lazyframe.frame.LazyFrame'>

La cadena es la misma que en modo inmediato —filtrar al puñado de seis géneros, agrupar, agregar con nombre y redondear, sin cast porque energy ya viaja en float64—, con scan_ donde antes decía read_. Pero el efecto es otro: la llamada vuelve al instante, aunque el Parquet ocupara gigabytes, porque no se ha leído ni una fila. resumen no contiene datos; es una receta. Hasta imprimirlo lo delata: print(resumen) no muestra tabla alguna —no la hay—, sino una descripción textual del trabajo pendiente encabezada por «naive plan», el plan ingenuo del que hablaremos enseguida. La ejecución solo ocurre al pedir el resultado con collect():

resultado = resumen.collect()   # AQUI se ejecuta todo el plan
print(resultado)
# shape: (6, 4)
# track_genre | media | n    | pico
# ---         | ---   | ---  | ---
# str         | f64   | u32  | f64
# classical   | 0.19  | 1000 | 0.953
# hip-hop     | 0.683 | 1000 | 0.994
# jazz        | 0.353 | 1000 | 0.938
# pop         | 0.606 | 1000 | 0.986
# reggaeton   | 0.739 | 1000 | 0.969
# rock        | 0.679 | 1000 | 0.99

collect() entrega el plan completo al optimizador y lo ejecuta de una sola vez; el resultado es un DataFrame corriente (como en el resto del capítulo, reproducimos la tabla simplificada a ASCII: el original enmarca las celdas con líneas Unicode). Las cifras cuadran una a una con el modo inmediato de la §9.2.3 y con el groupby de pandas del cap. 8 (§8.4): la firma de energy por género, de 0,19 en classical a 0,739 en reggaeton, sobre 1 000 pistas cada uno. Mismo dato, mismas cifras, cuarto camino.

Este esquema —declarar primero, ejecutar después— es exactamente el de una base de datos SQL aplicado al DataFrame. Una consulta SELECT no dice cómo recorrer las tablas: describe el resultado deseado, y es el planificador del motor quien elige el camino. polars traslada ese contrato a nuestro terreno: encadenamos verbos como si escribiéramos pandas, pero el motor recibe la frase completa antes de mover un solo byte, y con ella decide cómo. Cuando en la §9.4 hablemos SQL de verdad con DuckDB, el salto será corto: llevamos toda esta sección pensando como una base de datos.

La idea tampoco es nueva en este libro. En el cap. 5 practicamos la evaluación perezosa a mano: los generadores que recorrían un CSV línea a línea sin cargarlo entero (§5.9.1) y la lectura por trozos con la que agregamos más datos de los que cabían en memoria (§5.9.2). Entonces el optimizador éramos nosotros: decidíamos a mano qué columnas conservar y qué filas descartar, y en qué momento. scan_csv y scan_parquet institucionalizan aquella disciplina artesanal: la pereza deja de ser una técnica que aplicamos nosotros y pasa a ser el contrato del motor (Polars developers 2026).

El optimizador a la vista

¿Y qué optimiza exactamente el optimizador? No hace falta creerlo por fe: explain() imprime el plan real que collect() ejecutará. Tomemos una consulta sencilla sobre el catálogo —las pistas de classical, quedándonos con el género, la energía y lo acústico— y pidámosle cuentas; su salida es texto ASCII puro y la reproducimos tal cual la imprime:

consulta = (
    pl.scan_parquet("data/processed/musica.parquet")
    .filter(pl.col("track_genre") == "classical")
    .select("track_genre", "energy", "acousticness")
)
print(consulta.explain())
# Parquet SCAN [data/processed/musica.parquet]
# PROJECT 3/20 COLUMNS
# SELECTION: [(col("track_genre")) == ("classical")]
# ESTIMATED ROWS: 113999

El plan optimizado entero cabe en un único nodo: el escaneo del Parquet. Nuestro filter ha desaparecido como paso separado y reaparece dentro del escaneo, en la línea SELECTION: es el empuje de predicados (predicate pushdown), la condición viaja hasta el lector y las filas que no la cumplen se descartan según se leen, sin que la tabla completa llegue a existir en memoria. La línea PROJECT 3/20 COLUMNS es el empuje de proyecciones (projection pushdown): de las veinte columnas del fichero solo se leerán las tres que la consulta usa, y como Parquet es columnar (cap. 5), ese «no leer» es literal: los bytes de popularity, tempo y las otras quince ni siquiera salen del disco. Cierra el nodo una estimación de cardinalidad (ESTIMATED ROWS) obtenida de los metadatos: 113 999 filas por escanear.

¿Con qué lo estamos comparando? explain(optimized=False) imprime el plan ingenuo, el que calca nuestro código sin optimizar:

print(consulta.explain(optimized=False))
# SELECT [col("track_genre"), col("energy"), col("acousticness")]
#   FILTER [(col("track_genre")) == ("classical")]
#   FROM
#     Parquet SCAN [data/processed/musica.parquet]
#     PROJECT */20 COLUMNS
#     ESTIMATED ROWS: 113999

Los planes se leen de abajo hacia arriba: primero se ejecuta el nodo inferior. El ingenuo escanea el fichero completo (PROJECT */20 COLUMNS: las veinte columnas), después filtra y al final proyecta, con una tabla intermedia entre paso y paso. El optimizado hace el mismo trabajo sin salir del escaneo: al recogerla, consulta.collect() devuelve las mismas 1 000 filas de classical de las 113 999 del fichero por ambos caminos. Sobre este Parquet que cabe en memoria la diferencia es anecdótica; sobre uno de millones de filas, leer tres columnas de veinte y filtrar durante la lectura separa un paseo de una mudanza.

La agregación no estorba a nada de esto: si a consulta le encadenamos .group_by("track_genre") con la media de energy y de acousticness, el plan optimizado conserva un nodo AGGREGATE en cabeza que trabaja sobre el mismo escaneo podado, con su SELECTION y su PROJECT 3/20 COLUMNS intactos. La figura 9.3 resume la reescritura completa.

Figura 9.3. El optimizador reescribe el plan. El optimizador de polars reescribe el plan declarado (izquierda) antes de ejecutarlo: el filtro se convierte en la SELECTION del escaneo (empuje de predicados), la selección de columnas viaja al mismo nodo (PROJECT 3/20 COLUMNS, empuje de proyecciones) y la agregación trabaja sobre ese escaneo podado.

El repertorio del optimizador no se agota en los dos empujes: simplifica expresiones y elimina operaciones sin efecto, reordena pasos para adelantar los que reducen datos y detecta subplanes comunes —un mismo escaneo usado por dos ramas de la consulta— para calcularlos una sola vez y servirlos desde una caché (Polars developers 2026; Janssens y Nieuwdorp 2025). La lista crece con cada versión, y ante la duda explain() siempre cuenta la verdad de la versión instalada.

NotaAvanzado

Avanzado — Veinte años de motores columnares. Nada de esto lo inventó polars. El empuje de predicados y de proyecciones, la ejecución vectorizada sobre lotes de columnas o la estimación de cardinalidades son el pan de cada día de las bases de datos analíticas columnares: MonetDB y su motor vectorizado X100, gestados en el CWI de Ámsterdam, demostraron hace dos décadas cuánto rendimiento esconde ejecutar por columnas (Boncz et al. 2005), y la síntesis canónica de ese campo documenta las mismas técnicas que acabamos de ver en explain() (Abadi et al. 2013). La novedad de polars no son las técnicas, sino su domicilio: un optimizador de base de datos viviendo dentro de una biblioteca de DataFrames que se importa con una línea. DuckDB, nacido precisamente de esa tradición —y en la misma ciudad—, completará el cuadro en la §9.4.

Un DataFrame con plan de consulta y optimizador pide a gritos datos que le opongan resistencia. El resto del capítulo se los dará: el catálogo entero de música —y la maquinaria que escalaría a datos que no cupieran—, a cuya prueba someteremos todo lo anterior.

DuckDB: SQL analítico sobre tus ficheros

La tercera pieza de este capítulo no es una biblioteca de DataFrame, sino una base de datos. Mark Raasveldt y Hannes Mühleisen crearon DuckDB en el CWI de Ámsterdam y lo presentaron en 2019 como una base de datos analítica incrustada o embebida (embedded): un motor SQL completo que vive en el proceso de nuestro intérprete, sin servidor que instalar ni administrar (Raasveldt y Mühleisen 2019). Con licencia MIT y custodiado por la DuckDB Foundation, alcanzó la 1.0.0 en junio de 2024 y hoy va por la serie 1.5; nuestro entorno usa la 1.5.4 (DuckDB Foundation 2026).

Se le suele llamar «el SQLite de la analítica», y la etiqueta es exacta. SQLite, que conocimos junto a la DB-API en §5.8 (Lemburg 1999; Python Software Foundation 2026), es una base embebida para cargas transaccionales —filas sueltas que se buscan, insertan o modifican— y por eso almacena fila a fila. DuckDB copia la receta del despliegue (un import y cero configuración) e invierte el caso de uso: consultas analíticas que recorren tablas enteras filtrando, agrupando y agregando, con un motor columnar y vectorizado que procesa las columnas por lotes, el principio que acelera a NumPy, a Arrow y a polars.

De hecho, ya lo usamos: el integrador del cap. 5 lanzó una única consulta DuckDB sobre el Parquet de música (§5.10.6) como anticipo, y el cap. 8 prometió que el vocabulario SQL se aprendería aquí. Esta sección lo cumple con cinco construcciones —SELECT/WHERE, GROUP BY, ORDER BY/LIMIT, JOIN y NULL— que cubren la mayor parte del SQL analítico cotidiano.

El vocabulario SQL sobre un Parquet

Trabajaremos sobre data/processed/ musica.parquet, el fichero del pipeline del cap. 5: 113 999 pistas, 20 rasgos de audio, 114 géneros y cero ausentes. La función duckdb.sql() no exige abrir conexión ni cursor (usa una base en memoria global), y en el FROM puede ir, entre comillas simples, la ruta de un Parquet o CSV: DuckDB lo consulta in situ, sin cargarlo antes en ninguna estructura de Python. El resultado es una relación: una descripción de la consulta que, fiel al espíritu perezoso del capítulo, no se ejecuta hasta que alguien la materializa. (DuckDB imprime las relaciones con cajas de caracteres Unicode; en los comentarios de salida las simplificamos a trazos ASCII.)

import duckdb

rel = duckdb.sql("""
    SELECT track_name, popularity, energy
    FROM 'data/processed/musica.parquet'
    WHERE track_genre = 'reggaeton' AND popularity > 95
""")
print(rel)
# +------------------+------------+--------+
# |    track_name    | popularity | energy |
# |     varchar      |   int64    | double |
# +------------------+------------+--------+
# | Tití Me Preguntó |         97 |  0.715 |
# | Me Porto Bonito  |         97 |  0.712 |
# | La Bachata       |         98 |  0.679 |
# +------------------+------------+--------+

La consulta se lee sola: SELECT elige columnas (la proyección) y WHERE elige filas (el filtro). Solo tres reggaetones superan una popularidad de 95 —éxitos de Bad Bunny como Tití Me Preguntó y Me Porto Bonito— y bajo las cabeceras aparecen los tipos del esquema Parquet del cap. 5. Hay más de lo que parece: proyección y filtro son los dos empujes que el optimizador de polars aplicaba en §9.3.2, solo que aquí no hay nada que activar: el motor lee solo las columnas implicadas y salta bloques de filas usando las estadísticas del propio Parquet.

La agregación por grupos —nuestro patrón favorito desde §8.4— se escribe con GROUP BY y funciones de agregado (AVG, COUNT, MAX, SUM) en el SELECT:

print(duckdb.sql("""
    SELECT track_genre, ROUND(AVG(energy), 3) AS media,
           COUNT(*) AS n
    FROM 'data/processed/musica.parquet'
    WHERE track_genre IN ('classical', 'hip-hop', 'jazz',
                          'pop', 'reggaeton', 'rock')
    GROUP BY track_genre
    ORDER BY track_genre
""").fetchall())
# [('classical', 0.19, 1000), ('hip-hop', 0.683, 1000),
#  ('jazz', 0.353, 1000), ('pop', 0.606, 1000),
#  ('reggaeton', 0.739, 1000), ('rock', 0.679, 1000)]

fetchall() devuelve una lista de tuplas al más puro estilo de la DB-API, y las cifras cuadran con las que pandas nos dio en §8.4: la firma de energy por género, 0.19 en classical y 0.739 en reggaeton, sobre 1 000 pistas cada uno. El ORDER BY final no es decorativo: sin él, SQL no garantiza ningún orden en el resultado. Esa misma cláusula, combinada con LIMIT, da el idioma del «top-\(N\)»:

top = duckdb.sql("""
    SELECT track_name, track_genre, popularity
    FROM 'data/processed/musica.parquet'
    ORDER BY popularity DESC, track_name, track_genre
    LIMIT 5
""")
print(top)
# +---------------------------------------+-------------+------------+
# |              track_name               | track_genre | popularity |
# |                varchar                |   varchar   |   int64    |
# +---------------------------------------+-------------+------------+
# | Unholy (feat. Kim Petras)             | dance       |        100 |
# | Unholy (feat. Kim Petras)             | pop         |        100 |
# | Quevedo: Bzrp Music Sessions, Vol. 52 | hip-hop     |         99 |
# | I'm Good (Blue)                       | dance       |         98 |
# | I'm Good (Blue)                       | edm         |         98 |
# +---------------------------------------+-------------+------------+

DESC ordena de mayor a menor (el ascendente es el defecto) y las claves siguientes desempatan: el mismo éxito figura bajo varios géneros —Unholy aparece en dance y en pop, ambos con popularidad 100—, porque una pista puede catalogarse en más de uno. El top-5 lo encabezan Unholy y el BZRP de Quevedo.

Nos falta cruzar tablas: el fichero guarda el género de cada pista, pero la familia sonora a la que pertenece —acústica, eléctrica, urbana— la ponemos nosotros. WITH ... AS (VALUES ...) define una tabla auxiliar en la propia consulta —el catálogo de familias— y JOIN ... ON la empareja con las pistas:

por_familia = duckdb.sql("""
    WITH familias(track_genre, familia) AS (
        VALUES ('classical', 'acustica'),
               ('jazz', 'acustica'),
               ('pop', 'electrica'),
               ('rock', 'electrica'),
               ('hip-hop', 'urbana'),
               ('reggaeton', 'urbana')
    )
    SELECT f.familia, ROUND(AVG(m.energy), 3) AS energy,
           COUNT(*) AS n
    FROM 'data/processed/musica.parquet' AS m
    JOIN familias AS f ON m.track_genre = f.track_genre
    GROUP BY f.familia
    ORDER BY AVG(m.energy) DESC
""")
for fila in por_familia.fetchall():
    print(fila)
# ('urbana', 0.711, 2000)
# ('electrica', 0.643, 2000)
# ('acustica', 0.271, 2000)

JOIN es el equivalente SQL del merge que practicamos en §8.4.2: ON declara la condición de emparejamiento y los alias m y f abrevian cada tabla (existen también LEFT, RIGHT y FULL JOIN, primos del how= de pandas). Y aquí el dato real sí habla: la familia acústica (classical, jazz) suena a 0.271 de energy, menos de la mitad que la urbana (hip-hop, reggaeton) a 0.711, con la eléctrica (pop, rock) en medio. La firma acústica del género es real, y es la base de la clasificación por el sonido que abordaremos en los caps. 13 y 14.

Queda el ausente, que en SQL se llama NULL. Este catálogo llega completo, pero las 157 pistas con tempo igual a cero son lecturas rotas —un imposible físico—: NULLIF(tempo, 0) las convierte en NULL y deja ver las dos reglas del ausente. Primera: los agregados ignoran NULLAVG divide entre las válidas, COUNT(x) cuenta no nulos y COUNT(*) cuenta filas—, la misma política que pandas aplicaba en §8.3. Segunda: NULL no es comparable; tempo = NULL no selecciona nada (comparar con NULL da NULL) y lo correcto es IS NULL o IS NOT NULL:

print(duckdb.sql("""
    SELECT COUNT(*) AS filas,
           COUNT(NULLIF(tempo, 0)) AS validas,
           COUNT(*) - COUNT(NULLIF(tempo, 0)) AS rotas
    FROM 'data/processed/musica.parquet'
""").fetchall())
# [(113999, 113842, 157)]
print(duckdb.sql("""
    SELECT COUNT(*) FROM 'data/processed/musica.parquet'
    WHERE NULLIF(tempo, 0) IS NOT NULL
""").fetchall())
# [(113842,)]

Sobre energy, sin ausentes, AVG y COUNT cuentan las 113 999 pistas; el NULLIF y el IS NOT NULL solo hacen falta cuando queremos apartar las lecturas rotas.

DuckDB habla con todos

Una consulta sirve de poco si su resultado se queda dentro del motor. La relación de DuckDB se convierte a las tres estructuras tabulares del ecosistema con un método cada una:

medias = duckdb.sql("""
    SELECT track_genre, ROUND(AVG(energy), 3) AS media
    FROM 'data/processed/musica.parquet'
    GROUP BY track_genre
    ORDER BY track_genre
""")
df_pl = medias.pl()               # DataFrame de polars
df_pd = medias.df()               # DataFrame de pandas
tabla = medias.to_arrow_table()   # tabla de Arrow
print(type(df_pl), type(df_pd), type(tabla), sep="\n")
# <class 'polars.dataframe.frame.DataFrame'>
# <class 'pandas.core.frame.DataFrame'>
# <class 'pyarrow.lib.Table'>

Cuidado con .arrow(): desde la serie 1.5 no devuelve una tabla, sino un RecordBatchReader, un flujo de lotes para consumir resultados grandes por partes; la tabla la da to_arrow_table(). Estas conversiones cuestan poquísimo: el resultado ya es columnar y, como vimos en §9.1.3, Arrow ejerce de puente sin reserialización, así que mover el resultado a polars es casi gratis.

El puente funciona igual en sentido contrario: si el FROM menciona un nombre que no es tabla ni fichero, DuckDB busca en las variables de Python un DataFrame de pandas o de polars con ese nombre y lo consulta como si fuera una tabla: es el escaneo de sustitución (replacement scan):

df = pl.read_parquet("data/processed/musica.parquet")

picos = duckdb.sql("""
    SELECT track_genre, ROUND(MAX(energy), 3) AS pico
    FROM df                     -- df es la variable de arriba
    WHERE track_genre IN ('classical', 'pop', 'reggaeton')
    GROUP BY track_genre
    ORDER BY track_genre
""").pl()
print(picos.rows())
# [('classical', 0.953), ('pop', 0.986), ('reggaeton', 0.969)]

Los picos por género —0.953 en classical, 0.986 en pop— salen de la variable df sin que DuckDB la cargue en ninguna tabla propia. Con un DataFrame de pandas funcionaría exactamente igual; y para una base persistente o varias conexiones, duckdb.connect() devuelve el par conexión-cursor clásico de la DB-API de §5.8.

La promesa del vocabulario SQL queda saldada, y con ella tenemos dos lenguajes para la misma pregunta: la consulta SQL de DuckDB y la cadena de expresiones de polars. Gracias a Arrow, mezclarlos en un mismo pipeline —SQL para extraer y agregar, expresiones para el ajuste fino— apenas tiene peaje; cuándo conviene cada uno, y dónde queda pandas, lo discutiremos en §9.6.

Datos más grandes que la memoria

Seamos honestos antes de empezar: nada de lo que hemos manejado hasta aquí amenaza la memoria de un portátil corriente. Nuestro catálogo de música ocupa 8,7 megabytes en Parquet, y hasta un histórico de escuchas de años cabría holgadamente en la RAM de cualquier máquina moderna. Pero la frontera existe: tarde o temprano aparece el fichero de 50 GB en el portátil de 16, el histórico de reproducciones que nadie pensó en trocear, la exportación de la base de datos corporativa que «no abre». Las técnicas de esta sección —procesar por trozos y particionar el almacenamiento— son las que resuelven ese día, y tienen una virtud pedagógica: se aprenden exactamente igual con datos que sí caben. El código es el mismo; solo cambia el tamaño del problema.

Nuestro campo de pruebas será data/processed/musica.parquet, el catálogo que arrastramos desde el capítulo 5: las 113 999 pistas del Spotify Tracks Dataset, 20 rasgos de audio, 114 géneros y cero ausentes (maharshipandya 2022). El guion de acompañamiento src/cap09_polars.py lo consulta sin generar nada. En total, 113 999 pistas:

import polars as pl

lf = pl.scan_parquet("data/processed/musica.parquet")
resumen = lf.select(
    n=pl.len(),
    generos=pl.col("track_genre").n_unique(),
    rotas=(pl.col("tempo") == 0).sum(),
).collect()
print(resumen.row(0))
# (113999, 114, 157)

Un fichero de 8,7 MB, insistimos, no es «gran escala»; es una maqueta a la que aplicaremos, sin cambiar una línea, la maquinaria pensada para cuando los datos desbordan la memoria.

El motor en flujo

La evaluación perezosa que hemos explotado durante todo el capítulo tiene una consecuencia que aún no hemos aprovechado: si polars conoce el plan completo antes de ejecutar nada, puede decidir cómo ejecutarlo. Y una de sus opciones es hacerlo en flujo (streaming): en lugar de cargar la tabla entera y transformarla en memoria, el motor lee un trozo, lo empuja por todo el plan —filtro, agregado parcial—, lo descarta y pasa al siguiente. En ningún momento existe el dataset completo materializado; solo el trozo en curso y los acumuladores del resultado. Es importante saber que en polars 1.42 este motor es opcional: collect() a secas sigue usando el motor en memoria, y el modo en flujo se pide explícitamente con collect(engine="streaming"). (Si el lector encuentra por ahí collect(streaming=True), era la interfaz antigua: quedó obsoleta en la versión 1.25 y hoy sobrevive solo como alias con aviso de obsolescencia, condenado a desaparecer; el motor actual, reescrito por completo durante 2025, reparte el trabajo entre hilos en pequeñas porciones o morsels (Polars developers 2026).)

seis = ["classical", "hip-hop", "jazz", "pop", "reggaeton", "rock"]
consulta = (
    pl.scan_parquet("data/processed/musica.parquet")
      .filter(pl.col("track_genre").is_in(seis))
      .group_by("track_genre")
      .agg(media=pl.col("energy").mean(), n=pl.len())
      .sort("track_genre")
)
res = consulta.collect(engine="streaming")   # procesa por trozos
for genero, media, n in res.iter_rows():
    print(f"{genero}: media {media:.3f}  n={n}")
# classical: media 0.190  n=1000
# hip-hop: media 0.683  n=1000
# jazz: media 0.353  n=1000
# pop: media 0.606  n=1000
# reggaeton: media 0.739  n=1000
# rock: media 0.679  n=1000

print(res.equals(consulta.collect()))        # True

El resultado es idéntico al del motor en memoria —las medias por género que ya son viejas conocidas— y, con un fichero tan pequeño, el tiempo también es indistinguible: del orden de 20 ms por ambas vías en nuestra máquina. La diferencia no está en la velocidad sino en el techo: el consumo de memoria del motor en flujo depende del tamaño del trozo, no del tamaño del fichero.

Hay un segundo movimiento que completa la jugada. Si el destino del resultado no es seguir trabajando en Python sino otro fichero, ni siquiera hace falta recogerlo: sink_parquet ejecuta el plan en flujo y va escribiendo el resultado a disco por trozos, de modo que ni la entrada ni la salida pasan enteras por la RAM.

from pathlib import Path

plan = (
    pl.scan_parquet("data/processed/musica.parquet")
      .filter(pl.col("track_genre") == "reggaeton")
)
plan.sink_parquet("data/processed/reggaeton.parquet")

fichero = Path("data/processed/reggaeton.parquet")
print(f"{fichero.stat().st_size / 1024:.0f} KB")   # 72 KB

Acabamos de destilar las 1 000 pistas de reggaeton a un Parquet de 72 KB sin materializar nada. Con nuestra maqueta es un gesto; con el fichero de 50 GB en el portátil de 16 es la diferencia entre poder y no poder: la misma pareja scan_parquet + sink_parquet filtra, transforma y reescribe un dataset que jamás cabría en memoria. Y conviene reconocer el patrón: es exactamente lo que hicimos a mano en el capítulo 5 cuando leímos el CSV por bloques con pandas (§5.9.2), solo que industrializado. Allí elegíamos el tamaño del bloque, manteníamos los acumuladores y combinábamos los parciales nosotros; aquí el motor decide los trozos, paraleliza y funde los resultados sin que el código lo mencione. Los dos frentes que medimos en el capítulo 8 (§8.6), tiempo y memoria, quedan cubiertos: el plan optimizado ataca el primero y el flujo, el segundo.

Particionar y podar

La otra mitad de la estrategia no ocurre al leer, sino al guardar. Si sabemos que el catálogo se consultará casi siempre por género, podemos almacenarlo ya troceado por esa columna, en el llamado particionado al estilo Hive: un directorio por valor, con el valor codificado en el nombre. En polars basta write_parquet con el parámetro partition_by:

df = pl.read_parquet("data/processed/musica.parquet")
df.write_parquet("data/processed/musica_por_genero",
                 partition_by="track_genre")

ruta = Path("data/processed/musica_por_genero")
partes = sorted(ruta.iterdir(), key=lambda p: p.name)
for p in partes[:3]:
    fich = next(p.iterdir())
    print(f"{p.name}/{fich.name}  {fich.stat().st_size / 1024:.0f} KB")
# track_genre=acoustic/00000000.parquet  83 KB
# track_genre=afrobeat/00000000.parquet  83 KB
# track_genre=alt-rock/00000000.parquet  80 KB

Donde antes había un fichero ahora hay un directorio con 114 subdirectorios track_genre=acoustic/, …, track_genre=world-music/, cada uno con su Parquet de unos 80 KB (polars conserva además la columna track_genre dentro de cada fichero, así que el conjunto sigue siendo autocontenido). El precio es modesto: los 114 trozos suman 9,6 MB frente a los 8,7 del fichero único, porque la compresión por columnas (Apache Software Foundation 2026b) trabaja mejor con trozos grandes. La recompensa llega al consultar: scan_parquet acepta un patrón glob y, con hive_partitioning=True, interpreta los nombres de directorio como una columna más. Y aquí ocurre lo interesante: si el plan filtra por esa columna, el optimizador descarta los ficheros que no pueden contener filas útiles antes de abrirlos. Es la poda de particiones (partition pruning), y explain la delata:

lf = pl.scan_parquet("data/processed/musica_por_genero/**/*.parquet",
                     hive_partitioning=True)
clasica = (
    lf.filter(pl.col("track_genre") == "classical")
      .select(energy=pl.col("energy").mean().round(3),
              acustica=pl.col("acousticness").mean().round(3),
              n=pl.len())
)
print(clasica.explain())
# ...
# Parquet SCAN [.../musica_por_genero/track_genre=classical/...]
# PROJECT 3/20 COLUMNS
# SELECTION: [(col("track_genre")) == ("classical")]

print(clasica.collect().row(0))
# (0.19, 0.92, 1000)

El plan lo dice todo: de los 114 ficheros del glob, el SCAN lista uno —el de classical—, del que además proyecta solo tres columnas de veinte. Ese género aporta 1 000 pistas, y su firma es la que esperábamos: energy 0.19 y acousticness 0.92, música acústica de manual (en reggaeton el reparto se invertiría). En nuestra maqueta la poda apenas rasca unos milisegundos, porque todo cabe en la caché de disco; el argumento es de proporciones: la consulta lee una de cada 114 particiones, y sobre un catálogo de millones de pistas troceado por género, pedir uno tocaría un solo fichero. DuckDB, por cierto, juega al mismo juego con la misma convención: read_parquet(..., hive_partitioning = true) expone la columna de la ruta y poda particiones automáticamente con el predicado del WHERE (DuckDB Foundation 2026):

import duckdb

n_classical = duckdb.sql("""
    SELECT COUNT(*) FROM read_parquet(
        'data/processed/musica_por_genero/*/*.parquet',
        hive_partitioning = true)
    WHERE track_genre = 'classical'
""").fetchone()[0]
print(n_classical)   # 1000

Particionar es una decisión de diseño, no un automatismo: la columna de partición debe ser la que de verdad recorte las consultas frecuentes (el género aquí; a veces el artista, el país o el año de lanzamiento), y sin pasarse de fino, porque miles de particiones diminutas destruyen la ventaja que dan unas decenas de particiones razonables. Con esa decisión bien tomada, el dúo de esta sección —flujo para no desbordar la memoria, poda para no leer de más— cubre con un solo portátil un rango de tamaños que hace no tanto exigía un clúster.

Una medición a escala real: los taxis de Nueva York

Hemos aprendido el flujo y la poda con una maqueta, y lo hemos reconocido a cada paso: sobre 8,7 MB estas técnicas se explican, pero no se miden —el tiempo es indistinguible y el techo de memoria ni se roza—. Cerremos la sección midiéndolas una vez, de verdad, sobre datos que ya incomodan, para después soltar el dato y volver al catálogo que vertebra el libro. El conjunto canónico para esto es público y nativo en Parquet: los registros de viajes del Taxi and Limousine Commission de Nueva York, publicados por meses (New York City Taxi and Limousine Commission 2024). El guion src/cap09_taxis.py descarga un año del taxi amarillo —los doce ficheros mensuales de 2024— y lo consulta; los datos no se versionan (varios cientos de megabytes), se regeneran con una orden.

GLOB = "data/nyc_taxi/yellow_tripdata_2024-*.parquet"
print(pl.scan_parquet(GLOB).select(pl.len()).collect().item())
# 41169720   (doce ficheros mensuales, 661 MB en disco)

Cuarenta y un millones de viajes: dos órdenes de magnitud sobre la maqueta, y ya no un juguete. Ahora la promesa del motor en flujo —«el consumo de memoria depende del tamaño del trozo, no del tamaño del fichero»— se puede poner en la balanza. Planteamos una consulta que obliga a recorrer el año entero pero cuyo resultado es diminuto —el gasto medio y el número de viajes por número de pasajeros— y la resolvemos por las dos vías:

consulta = (
    pl.scan_parquet(GLOB)
      .group_by("passenger_count")
      .agg(gasto=pl.col("total_amount").mean(), n=pl.len())
)
res = consulta.collect(engine="streaming")   # en flujo, por trozos

# La misma consulta por las dos vias, cada una en un proceso limpio
# (src/cap09_taxis.py), midiendo el pico de RAM residente:
#   en memoria (carga las 19 columnas):   777 ms   pico 6156 MB
#   en flujo   (proyecta 2, por trozos):  133 ms   pico  759 MB
#   -> x8 menos memoria, y de paso mas rapido

El resultado numérico es idéntico por ambas vías; lo que cambia es el techo. El enfoque ingenuo —cargar el año entero con read_parquet y luego agregar— materializa los cuarenta y un millones de filas con sus diecinueve columnas, y su pico roza los 6 GB. El motor perezoso ve que la pregunta solo toca dos columnas, proyecta únicamente esas, y las empuja por el plan en morsels sin guardar nunca más que el trozo en curso, de modo que se queda por debajo del gigabyte —unas ocho veces menos—. Sobre esta máquina de 62 GB las dos vías caben; en el portátil de 16 con el navegador abierto, esos 6 GB son la diferencia entre la consulta que corre y la que muere con MemoryError. No es la velocidad —aunque también—: es no tener que caber.

Un apunte del oficio, de paso: aquel read_parquet del glob completo ni siquiera arranca sin ayuda, porque los ficheros del TLC mezclan la resolución de sus columnas de fecha —microsegundos unos meses, nanosegundos otros— y la unión exige unificarlas antes (el guion lo hace con un cast). El dato real rara vez es tan cortés como la maqueta; domesticarlo es el oficio del capítulo 10.

Y la poda de particiones, que en la maqueta apenas rascaba milisegundos, aquí se ve. Guardado el año troceado por mes al estilo Hive (§9.5.2) y pedido un solo mes, el escáner abre un fichero de los doce:

lf = pl.scan_parquet("data/nyc_taxi/por_mes/**/*.parquet",
                     hive_partitioning=True)
marzo = (lf.filter(pl.col("mes") == "2024-03")
           .select(gasto=pl.col("total_amount").mean(), n=pl.len()))
# el plan lista 1 de los 12 ficheros; la consulta tarda 36 ms

Leer uno de cada doce ficheros no es economía retórica cuando cada uno pesa medio centenar de megabytes; y sobre un histórico de varios años troceado por mes, la misma consulta tocaría esa fracción del total sin que el código cambie. El argumento queda así medido, no proclamado: sobre un año de viajes reales, un solo portátil —sin clúster— agrega en flujo y poda particiones con la misma sintaxis que aplicamos al catálogo de música. Soltamos aquí el taxi; el resto del libro vuelve a la música.

NotaAvanzado

Avanzado — Cuando una máquina no basta. Queda un último peldaño, que este libro solo señala. El mismo plan perezoso de polars puede ejecutarse en una GPU con collect(engine="gpu"), que delega en cuDF, la implementación columnar del ecosistema RAPIDS de NVIDIA (Polars developers 2026; Okuta et al. 2017): en beta abierta desde 2024 (y aún en beta en 2026), requiere una GPU NVIDIA y el extra polars[gpu], y en agregados masivos puede multiplicar la velocidad. Si el límite es la máquina entera, Dask (Rocklin 2015) trocea DataFrames de pandas y arrays de NumPy y los reparte entre procesos o entre nodos de un clúster con una API deliberadamente familiar; y la propia Polars ofrece desde 2025 Polars Cloud, disponible de forma general sobre AWS con su motor distribuido aún en beta. Pero el orden de los peldaños importa: escalar hacia fuera (scale out) multiplica costes y complejidad —red, serialización, fallos parciales—, así que solo compensa cuando escalar hacia dentro (formato columnar, planes perezosos, flujo, particiones) está de verdad agotado (Gorelick y Ozsvald 2020). Con lo visto en este capítulo, ese punto queda mucho más lejos de lo que suele creerse.

pandas, polars o SQL: traducción y criterio

Al llegar aquí el lector maneja tres sintaxis para un mismo repertorio de operaciones: la de pandas, que dominó el capítulo 8; la de polars, protagonista de este; y el SQL analítico de DuckDB. Anunciamos entonces que este contraste exigía conocer bien pandas, y es el momento de cobrar esa promesa: primero un diccionario de traducción operación a operación, después un criterio honesto para elegir. La tesis de la sección: las tres herramientas son dialectos de un mismo modelo, y quien domina el modelo traduce entre ellas con soltura.

El diccionario de traducción

La tabla 9.1 recoge, cara a cara, operaciones que el lector ya ejecutó con pandas: el agregado por grupos de la §8.4, la combinación de tablas de la §8.4.2 o el remodelado de la §8.4.3. La columna derecha no es una transcripción de la documentación: cada equivalencia la hemos ejecutado sobre nuestro Parquet de música.

Equivalencias entre pandas y polars. Diccionario de traducción entre pandas y polars para las operaciones de los capítulos 8 y 9, verificado con pandas 2.3.3 y polars 1.42.1.
Operación pandas polars
Leer Parquet pd.read_parquet(f) pl.read_parquet(f); perezoso: pl.``scan_parquet(f)
Filtrar filas df[df["v"] > 0] df.filter(pl.col("v") > 0)
Columna nueva df["c"] = expr o df.assign(c=expr) df.with_columns(c=expr)
Agregar por grupos df.groupby("g")["v"]``.mean() df.group_by("g")``.agg(``pl.col("v").mean())
Combinar tablas df.merge(otro, on="k") df.join(otro, on="k")
Tabla dinámica df.pivot_table(index, columns, values, aggfunc) df.pivot(on, index, values, aggregate_function)
Ventana por grupo df.groupby("g")["v"]``.transform("median") pl.col("v")``.median().over("g")
Ordenar df.sort_values("v", ascending=False) df.sort("v", descending=True)
Contar valores df["g"]``.value_counts() df["g"]``.value_counts()
Quitar ausentes df.dropna(subset=["v"]) df.drop_nulls("v")
Quitar duplicados df.drop_duplicates() df.unique()
Renombrar df.rename(columns=d) df.rename(d)

Dos avisos antes de usarla. Primero, la tabla es un mapa, no una biyección: los valores por defecto difieren en detalles que solo se descubren ejecutando (al ordenar, polars coloca los null al principio y pandas deja los NaN al final). Segundo, hay tres diferencias de fondo que despistan al traducir, y merecen algo más.

La primera: en polars no hay índice de filas. Es una decisión de diseño declarada: cada fila se identifica por su posición, y punto. Lo que en pandas pasaba por .loc y una etiqueta se convierte en un filtro sobre una columna corriente:

import pandas as pd
import polars as pl

ruta = "data/processed/musica.parquet"
medias_pd = (pd.read_parquet(ruta)
               .groupby("track_genre", observed=True)["energy"].mean())
print(medias_pd.loc["classical"])   # el indice hace de llave
# 0.189827326

medias_pl = (pl.read_parquet(ruta)
               .group_by("track_genre")
               .agg(pl.col("energy").mean()))
print(medias_pl.filter(pl.col("track_genre") == "classical"))
# shape: (1, 2)
# +-------------+-----------+
# | track_genre | energy    |
# | ---         | ---       |
# | str         | f64       |
# +-------------------------+
# | classical   | 0.189827  |
# +-------------+-----------+

Obsérvese la contrapartida: en pandas, groupby convirtió la clave en índice, y devolverla a columna habría exigido el consabido reset_index; en polars el resultado de group_by ya es una tabla plana con track_genre como columna normal. La alineación automática por etiqueta, eso sí, hay que reformularla como un join explícito.

La segunda: no existe el dilema vista o copia. En la §7.4.2 distinguimos vistas de copias sobre arrays y en la §8.6.3 vimos a pandas resolver la ambigüedad con la copia al escribir. polars corta por lo sano: no hay asignación sobre porciones ni SettingWithCopyWarning, porque cada operación devuelve un DataFrame nuevo y el original queda intacto (gracias a Arrow, «nuevo» no significa «duplicado»: las columnas no modificadas se comparten en memoria):

df = pl.read_parquet(ruta)
dobles = df.with_columns(pl.col("energy") * 2)
print(dobles is df)                    # False: objeto nuevo
print(round(df["energy"].max(), 1))    # el original, intacto
# 1.0
print(round(dobles["energy"].max(), 1))
# 2.0

La tercera: el texto es un tipo de primera clase. Donde pandas arrastraba el dtype object, como vimos en el capítulo 8, polars siempre ha tenido String —alias histórico Utf8— y Categorical estrictos:

print(pl.Series(["pop", "rock"]).dtype)               # String
print(df["track_genre"].cast(pl.Categorical).dtype)   # Categorical

El criterio

Con el diccionario en la mano, queda la pregunta incómoda: ¿cuál usar? No hay una respuesta única; hay un criterio.

  • pandas sigue siendo la elección por su ecosistema (McKinney 2022; The pandas development team 2026): la mayoría de tutoriales, respuestas de foros e integraciones —scikit-learn, matplotlib, statsmodels— asumen un DataFrame de pandas en la frontera. Para tablas medianas, exploración interactiva y aprendizaje es un punto de partida razonable, mucho mejor tras el capítulo 8.

  • polars gana cuando mandan el rendimiento o la memoria (Polars developers 2026; Janssens y Nieuwdorp 2025): tablas grandes, cadenas largas de transformaciones, paralelismo sin esfuerzo y, con el motor en flujo, más datos que RAM. Su plan de consulta optimizado ataca los dos frentes —tiempo y memoria— que medimos en la §8.6, y sus pipelines declarativos son más legibles cuanto más complejos.

  • SQL sobre DuckDB brilla cuando el problema es naturalmente relacional —uniones y agregados sobre ficheros en disco, sin cargar nada— y cuando hay que compartir el resultado: una consulta SQL la reutiliza gente que jamás abrirá Python.

La recomendación pedagógica de este libro es otra: aprende el modelo y trata las herramientas como dialectos. Lo que hay que interiorizar —la organización columnar, la vectorización del capítulo 7, la evaluación perezosa, el dividir-aplicar-combinar de la §8.4— es común a las tres: pandas nació para traer ese modelo de tablas y agregados al ecosistema Python (McKinney 2010), y polars y DuckDB lo reimplementan. Quien entiende el modelo cambia de herramienta en una tarde; quien memoriza métodos, no.

Como demostración, la misma pregunta —¿cuánta energy tiene cada familia sonora?— en los tres dialectos, sobre el Parquet que dejó el capítulo 5 y un catálogo de familias. En pandas:

import duckdb
import pandas as pd
import polars as pl

ruta = "data/processed/musica.parquet"
catalogo = pd.DataFrame({
    "track_genre": ["classical", "jazz", "pop",
                    "rock", "hip-hop", "reggaeton"],
    "familia": ["acustica", "acustica", "electrica",
                "electrica", "urbana", "urbana"],
})

musica = pd.read_parquet(ruta)
energy_pd = (musica
             .merge(catalogo, on="track_genre")
             .groupby("familia")["energy"]
             .mean().round(3))
print(energy_pd)
# familia
# acustica     0.271
# electrica    0.643
# urbana       0.711
# Name: energy, dtype: float64

En polars, perezoso de principio a fin (el catálogo pandas cruza el puente Arrow con pl.from_pandas, sin copiar datos):

energy_pl = (
    pl.scan_parquet(ruta)
      .join(pl.from_pandas(catalogo).lazy(), on="track_genre")
      .group_by("familia")
      .agg(energy=pl.col("energy").mean().round(3))
      .sort("familia")
      .collect()
)
print(energy_pl)
# shape: (3, 2)
# +-----------+--------+
# | familia   | energy |
# | ---       | ---    |
# | str       | f64    |
# +====================+
# | acustica  | 0.271  |
# | electrica | 0.643  |
# | urbana    | 0.711  |
# +-----------+--------+

Y en SQL, donde DuckDB lee el fichero por su ruta y encuentra la variable catalogo por la sustitución de tablas que ya usamos:

energy_sql = duckdb.sql("""
    SELECT familia, round(avg(energy), 3) AS energy
    FROM 'data/processed/musica.parquet'
    JOIN catalogo USING (track_genre)
    GROUP BY familia
    ORDER BY familia
""").pl()
print(energy_sql)
# (la misma tabla, fila a fila, que la version polars)

Tres sintaxis, un plan: unir con el catálogo, agrupar, promediar. La respuesta es idéntica —0,271 de energy en la familia acústica frente a 0,711 en la urbana, con la eléctrica en medio— y esta vez el dato real sí separa: la firma acústica del género es una señal de verdad. La elección del dialecto es, aquí, cuestión de contexto.

NotaAvanzado

Avanzado — El error de comparar velocidades sin contexto. Circulan muchos benchmarks que proclaman que tal herramienta es N veces más rápida que tal otra. Casi todos omiten lo esencial: esas cifras dependen de la operación concreta, del tamaño de los datos, del número de núcleos y de la versión de cada biblioteca, y cualquiera de esos factores mueve el resultado en múltiplos enteros. La referencia seria del sector es el db-benchmark (DuckDB Labs, s. f.): agregados y uniones reproducibles sobre tablas de 0,5, 5 y 50 GB, con código y hardware publicados. Lo inició Matt Dowle en 2014 y hoy lo mantiene DuckDB Labs; es un trabajo riguroso, pero conviene leerlo sabiendo que su mantenedor es también uno de los actores del ranking. De sus resultados basta retener lo robusto: polars y DuckDB figuran de forma consistente entre los motores más rápidos. El factor exacto, en cambio, es un dato que se cita —con fuente, versión y condiciones, como exigirá la política de datos del capítulo 10— o que se mide: los cocientes de nuestra máquina, sobre nuestros datos, están en la §9.7, y no son una constante universal.

Un ejemplo integrador: el catálogo de punta a punta

Cerramos el capítulo donde el cap. 8 dejó la palabra dada: su ejemplo integrador (§8.7) terminaba anunciando que polars y DuckDB repetirían aquellos análisis, y este es el momento de cumplirlo. El terreno de juego es el musica.parquet que arrastramos desde el pipeline del cap. 5 (§5.10): las 113 999 pistas del Spotify Tracks Dataset (maharshipandya 2022), sus 20 rasgos de audio y sus 114 géneros. El guion es el viaje entero de un análisis: cargar el dato, agregarlo con el plan perezoso de polars, cruzarlo con SQL contra un catálogo de familias y entregar el resultado a NumPy.

No hay generador: el catálogo es real y ya viene limpio del cap. 5. Lo que ponemos nosotros es el catálogo de familias sonoras —acústica, eléctrica, urbana— que agrupa los seis géneros de firma reconocible. El código completo acompaña al capítulo en src/cap09_polars.py; este es su núcleo:

# extracto de src/cap09_polars.py (completo en el repositorio)
SEIS = ["classical", "hip-hop", "jazz", "pop",
        "reggaeton", "rock"]  # el punado de firma clara
FAMILIAS = [                  # catalogo de familias sonoras
    ("classical", "acustica"), ("jazz", "acustica"),
    ("pop", "electrica"), ("rock", "electrica"),
    ("hip-hop", "urbana"), ("reggaeton", "urbana"),
]

La comprobación de recepción, costumbre del cap. 5, cuadra:

import polars as pl

df = pl.read_parquet("data/processed/musica.parquet")
print(df.shape)                                  # (113999, 20)
print(df.null_count().sum_horizontal().item())   # 0
print((df["tempo"] == 0).sum())                  # 157

Primer análisis: la tríada de media, recuento y pico de energy por género con la que el cap. 8 resumió el catálogo (§8.4). Allí era un groupby de pandas sobre una tabla ya cargada; aquí, un plan perezoso con el filtro del puñado declarado antes de leer un solo byte, ejecutado con el motor en flujo (Polars developers 2026):

seis = ["classical", "hip-hop", "jazz", "pop", "reggaeton", "rock"]
resumen = (
    pl.scan_parquet("data/processed/musica.parquet")
      .filter(pl.col("track_genre").is_in(seis))
      .group_by("track_genre")
      .agg(
          pl.col("energy").mean().round(3).alias("media"),
          pl.len().alias("n"),
          pl.col("energy").max().round(3).alias("pico"),
      )
      .sort("track_genre")
      .collect(engine="streaming")
)
print(resumen)
# shape: (6, 4)
# track_genre  media  n     pico
# str          f64    u32   f64
# classical    0.19   1000  0.953
# hip-hop      0.683  1000  0.994
# jazz         0.353  1000  0.938
# pop          0.606  1000  0.986
# reggaeton    0.739  1000  0.969
# rock         0.679  1000  0.99

El optimizador hace lo que el capítulo prometió: .explain() muestra PROJECT 2/20 COLUMNS —solo viajan track_genre y energy— y el filtro empujado a la lectura como SELECTION. Las cifras dialogan con las de pandas (§8.4): la firma de energy separa los géneros con nitidez, de 0,19 en classical —música acústica, apenas energía— a 0,739 en reggaeton, con los picos rozando 1,0 en todos porque cualquier género esconde alguna pista muy energética. Cada género tiene su huella, y esa huella es la que un modelo aprenderá en los caps. 13 y 14.

El segundo análisis pide un cruce, y ahí el vocabulario natural es el SQL. La pregunta «¿difiere la energy según la familia sonora?» no se responde con la tabla de pistas sola: la familia —acústica, eléctrica o urbana— vive en el catálogo que pusimos nosotros, la clásica tabla de dimensión pequeña que se une a una tabla de hechos grande. Son seis filas —dos géneros por familia—, y están en src/cap09_polars.py:

catalogo = pl.DataFrame({      # el catalogo de familias
    "track_genre": ["classical", "jazz", "pop",
                    "rock", "hip-hop", "reggaeton"],
    "familia": ["acustica", "acustica", "electrica",
                "electrica", "urbana", "urbana"],
})

DuckDB ve la variable catalogo directamente desde el SQL —el replacement scan que ya conocemos funciona igual con DataFrames de polars (DuckDB Foundation 2026)— y .pl() trae el resultado de vuelta a polars, cerrando el círculo que abrió el estreno de DuckDB en el cap. 5 (§5.10.6):

import duckdb

por_familia = duckdb.sql("""
    SELECT c.familia,
           COUNT(*)                AS n,
           ROUND(AVG(m.energy), 3) AS energy
    FROM 'data/processed/musica.parquet' AS m
    JOIN catalogo AS c USING (track_genre)
    GROUP BY c.familia
    ORDER BY energy DESC
""").pl()
print(por_familia)
# shape: (3, 3)
# familia    n     energy
# str        i64   f64
# urbana     2000  0.711
# electrica  2000  0.643
# acustica   2000  0.271

Leamos el resultado con honestidad, porque contiene la lección que da sentido al capítulo: las tres medias se separan sin ambigüedad. La familia urbana (hip-hop, reggaeton) casi triplica en energy a la acústica (classical, jazz), con la eléctrica en medio. No es un artefacto del JOIN: la señal está en los datos, porque cada género tiene una firma acústica real —y por eso el sonido predice el género, la tarea de los caps. 13 y 14—. Lo que no predice el sonido es la popularidad, cuya correlación con estos rasgos es casi nula; esa asimetría —señal para el género, ruido para el éxito— es la que un análisis honrado debe reconocer.

Queda el último tramo: salir hacia el ecosistema numérico. El resultado es diminuto —tres filas— y ese es justamente el patrón: reducir a escala columnar y modelar sobre el resumen.

X = por_familia["energy"].to_numpy()
print(type(X).__name__, X)   # ndarray [0.711 0.643 0.271]
matriz = resumen.select("media", "pico").to_numpy()
print(matriz.shape, matriz.dtype)   # (6, 2) float64

Ese ndarray y esa matriz son exactamente lo que esperan scikit-learn y los modelos que construiremos en los caps. 13 y 14; para statsmodels bastaría .to_pandas(). Y conviene notar quién ha sostenido el viaje: el Parquet nació de una tabla Arrow en el cap. 5, polars y DuckDB lo han consultado sin reserializarlo, el resultado ha cruzado de DuckDB a polars por el puente de la §9.1.3 y solo ahora, ya con tres filas, los datos abandonan el mundo columnar. Arrow ha sido el pegamento de todo el trayecto, incluso donde no lo hemos nombrado.

Para terminar, pongamos números de nuestra máquina al capítulo entero, con time.perf_counter y el mejor de tres intentos:

import time
import pandas as pd

CSV = "data/processed/musica.csv"
PARQUET = "data/processed/musica.parquet"

def cronometra(etiqueta, funcion, repes=3):
    tiempos = []
    for _ in range(repes):
        inicio = time.perf_counter()
        funcion()
        tiempos.append(time.perf_counter() - inicio)
    print(f"{etiqueta:<22} {min(tiempos) * 1000:4.0f} ms")

cronometra("pandas, CSV",
           lambda: pd.read_csv(CSV)
                     .groupby("track_genre")["energy"].mean())
cronometra("polars lazy, CSV",
           lambda: pl.scan_csv(CSV).group_by("track_genre")
                     .agg(pl.col("energy").mean()).collect())
cronometra("pandas, parquet",
           lambda: pd.read_parquet(PARQUET)
                     .groupby("track_genre", observed=True)["energy"]
                     .mean())
cronometra("polars lazy, parquet",
           lambda: pl.scan_parquet(PARQUET).group_by("track_genre")
                     .agg(pl.col("energy").mean()).collect())
cronometra("duckdb, parquet",
           lambda: duckdb.sql(f"""
               SELECT track_genre, AVG(energy)
               FROM '{PARQUET}' GROUP BY track_genre""").pl())
# pandas, CSV             245 ms
# polars lazy, CSV         10 ms
# pandas, parquet          60 ms
# polars lazy, parquet      3 ms
# duckdb, parquet           3 ms

Dos lecturas, ambas con el matiz de rigor: son órdenes de magnitud de nuestra máquina, no constantes universales. La dramática está en el CSV: del orden de 245 ms frente a 10, unas veinticinco veces menos, porque el plan perezoso ni siquiera parsea las columnas que no va a usar, mientras que pandas materializa la tabla entera antes de agregar. Y el Parquet no es del todo sereno: pandas ronda los 60 ms porque carga las veinte columnas —cuatro de ellas cadenas grandes—, mientras que polars y DuckDB (Raasveldt y Mühleisen 2019) bajan a unos pocos, porque proyectan solo track_genre y energy sin tocar el resto. Sobre 113 999 filas cualquiera de los cinco caminos es instantáneo a efectos prácticos; los cocientes, no las cifras, son lo que se traslada a tamaños mayores, y ni siquiera ellos son sagrados: como argumentamos en la §9.6.2, el único benchmark que debe decidir una arquitectura es el que se ejecuta sobre los datos, las consultas y la máquina propios.

El modelo que este capítulo deja en pie cabe en una línea: datos columnares compartidos (Arrow, Parquet), transformaciones descritas como expresiones, un optimizador que decide el cómo —perezoso, en flujo, con empuje de predicados y proyecciones— y el SQL como segunda lengua franca sobre los mismos ficheros. Con él, el catálogo entero se ha cargado, agregado, cruzado y entregado a NumPy en unas decenas de milisegundos, sin salir de un portátil. Pero todo el edificio descansa en un lujo que casi no se nota: nuestro catálogo llega impecable, con tipos correctos y sin ausentes. El dato crudo rara vez es tan cortés —el fichero original traía sus tempos a cero, sus duraciones imposibles y sus géneros repetidos—, y limpiarlo y validarlo es un oficio en sí mismo: el del cap. 10.

Ejercicios

Los siguientes ejercicios recorren, de forma acumulativa, los conceptos desarrollados a lo largo del capítulo: de las expresiones y sus contextos al modo perezoso y su optimizador, del SQL analítico de DuckDB al particionado, el streaming y el intercambio sin copia que Arrow hace posible. Se recomienda resolverlos con un intérprete abierto, leyendo los planes de consulta antes de ejecutarlos y comprobando cada cifra. El código de apoyo del capítulo vive en src/cap09_polars.py; los enunciados asumen las versiones fijadas por la edición, polars==1.42.1 y duckdb==1.5.4, y trabajan con el fichero del capítulo, el catálogo de música (maharshipandya 2022): data/processed/``musica.parquet (113 999 pistas, 20 rasgos de audio, 114 géneros, cero ausentes), con su gemelo musica.csv de 19 MB.

  1. Del groupby a las expresiones. Repita en polars, en modo ansioso, la agregación por género que pandas resolvió en la §8.4: cargue el catálogo con pl.read_parquet, quédese con el puñado de seis géneros y calcule por género la media de energy, el número de pistas y el pico. (a) Escríbalo como un único encadenamiento group_by("track_genre")``.agg(...) con tres expresiones nombradas (§9.2.2); debe obtener las cifras del cap. 8: media 0,19, 1 000 pistas y pico 0,953 para classical; 0,739, 1 000 y 0,969 para reggaeton; 0,353, 1 000 y 0,938 para jazz. Observe que pl.col("energy").count() ignoraría los nulos, igual que el count de pandas, aunque este catálogo no tenga. (b) Compare la legibilidad de ambas versiones: en pandas, la agregación nombrada trocea el cálculo en tuplas (columna, función); en polars, cada expresión declara el cálculo completo y podría trasplantarse tal cual a un filter o a un with_columns (§9.2.3). ¿Cuál comunica mejor la intención a quien lee? (c) El resultado de polars es una tabla plana, sin índice: ¿qué paso habitual de pandas (reset_index) desaparece aquí, y por qué no existe siquiera como concepto (§9.2)?

  2. Leer el plan antes de ejecutarlo. Convierta a modo perezoso la consulta «media de energy por género de las pistas explícitas» sobre el mismo fichero: pl.``scan_parquet, un filter por explicit (y el puñado de seis) y un group_by("track_genre") con la media (§9.3). (a) Antes de ningún collect, imprima .explain() y localice las dos líneas que delatan al optimizador (§9.3.2): PROJECT 3/20 COLUMNS —el empuje de proyecciones: solo tres columnas saldrán del Parquet— y SELECTION: con el predicado —el empuje del filtro hasta el lector—. (b) Explique por qué se leen 3 de las 20 columnas si el resultado final solo tiene 2 (track_genre y la media): ¿para qué hace falta la tercera, explicit? Note además que classical no aparece: no hay pistas clásicas marcadas como explícitas. (c) Ejecute con collect() y compruebe que el resultado coincide con el de la misma consulta en modo ansioso: la pereza cambia el cómo, nunca el qué.

  3. La misma pregunta, ahora en SQL. Reproduzca la agregación del ejercicio 1 con DuckDB (§9.4.1), consultando el Parquet sin cargarlo: duckdb.sql sobre read_parquet(...) con GROUP BY track_genre y ORDER BY track_genre, filtrando el puñado de seis. (a) Seleccione round(avg(energy), 3), count(energy) y max(energy) y compruebe que las cifras clavan las del ejercicio 1; count(energy) descartaría los NULL sin que nadie se lo pida —tercera biblioteca con el mismo convenio—, aunque este catálogo no los tenga. (b) Recupere el resultado como DataFrame de polars con .pl() y verifique la igualdad programáticamente; ¿por qué el ORDER BY no es opcional en esa comprobación? (c) Con la tabla 9.1 delante, empareje cada cláusula del SQL con el método de polars del ejercicio 1: son la misma consulta con dos ortografías, y conviene saber leer y escribir ambas (§9.6).

  4. Todo el puñado en una unión. Reconstruya el catálogo de familias sonoras —"acustica", "electrica" o "urbana"— que agrupa los seis géneros de firma clara, recuperándolo de src/cap09_polars.py. (a) Una las pistas de esos seis géneros de musica.parquet con el catálogo mediante join(..., on="track_genre", how="left") y agregue por familia: debe obtener 2 000 pistas en cada una de las tres familias —dos géneros de 1 000 cada uno—, 6 000 en total. (b) Las medias por familia se separan con nitidez (acústica 0,271, eléctrica 0,643 y urbana 0,711): explique por qué este dataset real sí exhibe el contraste que un generador sintético homogéneo no podría —cada género tiene una firma acústica propia—. (c) Repita la unión con validate="m:1" y provoque después el fallo duplicando una fila del catálogo: el ComputeError inmediato es el mismo contrato contra duplicados silenciosos que merge ofrecía en la §8.4.2.

  5. Resumir el catálogo sin materializarlo. Construya en modo perezoso el resumen «media de energy y número de pistas por género» y escríbalo con sink_parquet (§9.5.1), sin ningún collect intermedio. (a) Compruebe que el fichero resultante tiene 114 filas —una por género— y ocupa unos 2,6 KB. (b) Léalo y localice los tres géneros con mayor energy media: death-metal (0,931), grindcore (0,924) y metalcore (0,914) —el metal encabeza la energía, coherente con su sonido—. (c) Explique qué distingue esta tubería de un collect().write_parquet(...): sink_parquet evalúa el plan con el motor en flujo y nunca tiene el catálogo entero en memoria (§9.5). ¿En qué escenario deja eso de ser un matiz y se convierte en la única opción viable?

  6. Particionar por género y observar la poda. (a) Reescriba el catálogo con write_parquet(..., partition_by="track_genre") sobre un directorio musica_por_genero: obtendrá 114 subdirectorios track_genre=acoustic, …, con un fichero cada uno, el esquema Hive de la §9.5.2. (b) Abra el conjunto con pl.scan_parquet sobre el patrón "musica_por_genero/**/*.parquet" con hive_partitioning=True, filtre track_genre == "classical" y lea el explain(): el plan solo lista el fichero de classical —la poda descartó los otros 113 antes de leer un byte— y el collect() devuelve sus 1 000 pistas. (c) Cronometre la media de energy de classical contra la misma consulta sobre el fichero único filtrando por track_genre: en nuestra máquina, la versión particionada tarda una fracción, y la ventaja crecería con un catálogo de millones de pistas. ¿Por qué particionar por explicit sería, en cambio, una mala idea para consultas por género?

  7. Traducir pandas sin índice. El fragmento siguiente, calcado de los ejercicios del cap. 8, marca las pistas cuya energy supera la mediana de su propio género:

    seis = musica[musica["track_genre"].isin(SEIS)]
    mediana = seis.groupby("track_genre")["energy"].transform("median")
    altas = seis[seis["energy"] > mediana]

    (a) Tradúzcalo a polars sobre la tabla del ejercicio 1; la pieza clave es pl.col("energy")``.median().over("track_genre"), que calcula por grupo y difunde el resultado a cada fila dentro de la propia expresión (§9.2.3). Debe recuperar las mismas 2 969 filas que obtuvo el capítulo anterior (497, 499, 483, 497, 494 y 499 por género). (b) Señale qué desapareció en la traducción: transform existe en pandas para devolver una Series alineada por el índice con la tabla original; sin índice, over expresa «por grupo» directamente dentro del filter, y el paso intermedio se esfuma (tabla 9.1). (c) ¿Qué ocurriría con un ausente? En polars, null > mediana vale null y filter lo descarta; en pandas, NaN > x vale False: el mismo resultado por caminos semánticos distintos (§9.2). Este catálogo no trae ausentes, pero el convenio importa en cuanto aparezcan.

  8. La memoria también cuenta. El fichero musica.csv ocupa 19 MB en disco. (a) Cárguelo entero con pd.read_csv y mida memory_usage(deep=True)``.sum(): en nuestra máquina, unos 56 MB, casi el triple del fichero. Localice a los culpables con dtypes: cinco columnas objecttrack_id, artists, album_name, track_name y track_genre, cientos de miles de cadenas de Python— que ya diagnosticamos en la §8.6. (b) Obtenga las pistas de reggaeton con pl.scan_csv(...)``.filter(...).collect() y mida el estimated_size() del resultado: 1 000 filas y unos 0,19 MB, del orden de 300 veces menos que la tabla de pandas. Compruebe en el explain() que el predicado aparece como SELECTION del propio lector CSV. (c) Explique la diferencia de fondo: pandas materializa la tabla completa y filtra después; scan_csv lee por lotes, aplica el predicado durante la lectura y solo el resultado llega a existir (§9.3). ¿Qué opciones de read_csv (usecols, dtype) habrían mitigado el problema sin eliminarlo?

  9. La tubería completa. (Avanzado) Monte de punta a punta la tubería del capítulo (§9.7) para preparar una matriz de características: DuckDB pone el SQL; polars, la transformación; NumPy es el destino. (a) En una sola consulta de DuckDB, seleccione las pistas de los seis géneros unidas con el catálogo de familias del ejercicio 4 (JOIN ... USING (track_genre)), descartando las de tempo roto (WHERE NULLIF(tempo, 0) IS NOT NULL); recuerde que el DataFrame catalogo se consulta por el nombre de su variable de Python, como vimos en la §9.4. Deben salir 5 999 filas —de las 6 000, una tiene el tempo a cero—. (b) Recoja el resultado con .pl(), estandarice energy, convierta familia en variables indicadoras con to_dummies y remate con to_numpy(): una matriz de \(5\,999 \times 5\) (popularity, energy estandarizada y las tres indicadoras de familia) lista para los modelos del cap. 14. (c) Señale el papel de Arrow en cada frontera (§9.1): de DuckDB a polars, el resultado viaja sin copia gracias al formato columnar compartido y su C Data Interface; la única copia real ocurre al final, cuando to_numpy() reescribe las columnas Arrow como array bidimensional. ¿Por qué es inevitable esa última copia? Piense en el modelo de memoria del cap. 7: un ndarray exige un bloque contiguo de un solo tipo, sin null.

Lecturas recomendadas

  • Polars developers (2026) es la fuente normativa: su guía de usuario abre con un capítulo de conceptos —expresiones, contextos, plan perezoso— que constituye la mejor introducción breve al modelo mental de este capítulo, y fija el comportamiento exacto de la versión 1.42 que exploran los ejercicios 1, 2 y 7.

  • Janssens y Nieuwdorp (2025) es el libro de referencia sobre la biblioteca: desarrolla con calma y con casos reales lo que aquí cupo en un capítulo —expresiones, modo perezoso, streaming, la migración desde pandas— y es el paso natural para quien vaya a adoptar polars como herramienta principal.

  • DuckDB Foundation (2026) documenta el dialecto SQL analítico, la lectura directa de Parquet con particiones Hive y la integración con Python y Arrow: todo lo que piden los ejercicios 3, 6 y 9, en guías breves que se leen en minutos.

  • Apache Software Foundation (2026a) especifica el formato columnar en memoria y el C Data Interface: la pieza que explica por qué las fronteras del ejercicio 9 no copian datos y qué viaja exactamente entre bibliotecas cuando decimos «sin copia».

  • Abadi et al. (2013) es la teoría detrás de todo el capítulo: por qué el almacenamiento columnar domina las cargas analíticas —compresión, vectorización, materialización tardía—, contado por autores que construyeron esos sistemas, entre ellos Peter Boncz, del mismo CWI donde después nacería DuckDB.

  • Vink (2021) cuenta el diseño de primera mano: por qué su creador levantó polars sobre Rust, Arrow y un plan perezoso. Como complemento, (DuckDB Labs, s. f.) mantiene el banco de pruebas público de groupby y join sobre datos de 0,5 a 50 GB en el que polars y DuckDB figuran de forma consistente entre los más rápidos.

Referencias

Abadi, Daniel, Peter Boncz, Stavros Harizopoulos, Stratos Idreos, y Samuel Madden. 2013. «The Design and Implementation of Modern Column-Oriented Database Systems». Foundations and Trends in Databases 5 (3): 197-280. https://doi.org/10.1561/1900000024.
Apache Software Foundation. 2026a. «Apache Arrow Documentation». Apache Software Foundation. https://arrow.apache.org/docs/.
Apache Software Foundation. 2026b. «Apache Parquet Documentation». Apache Software Foundation. https://parquet.apache.org/docs/.
Boncz, Peter, Marcin Zukowski, y Niels Nes. 2005. «MonetDB/X100: Hyper-Pipelining Query Execution». Second Biennial Conference on Innovative Data Systems Research (CIDR 2005), Asilomar, CA, 225-37. https://www.cidrdb.org/cidr2005/papers/P19.pdf.
DuckDB Foundation. 2026. «DuckDB Documentation». https://duckdb.org/docs/.
DuckDB Labs. s. f. «Database-like Ops Benchmark». Accedido 2 de julio de 2026. https://duckdblabs.github.io/db-benchmark/.
Gorelick, Micha, y Ian Ozsvald. 2020. High Performance Python: Practical Performant Programming for Humans. 2.ª ed. O’Reilly Media.
Harris, Charles R., K. Jarrod Millman, Stéfan J. van der Walt, et al. 2020. «Array programming with NumPy». Nature 585: 357-62. https://doi.org/10.1038/s41586-020-2649-2.
Janssens, Jeroen, y Thijs Nieuwdorp. 2025. Python Polars: The Definitive Guide: Transforming, Analyzing, and Visualizing Data with a Fast and Expressive DataFrame API. O’Reilly Media.
Kleppmann, Martin. 2017. Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems. O’Reilly Media. https://www.oreilly.com/library/view/designing-data-intensive-applications/9781491903063/.
Lemburg, Marc-André. 1999. «PEP 249 – Python Database API Specification v2.0». No. 249. Python Software Foundation. https://peps.python.org/pep-0249/.
maharshipandya. 2022. «Spotify Tracks Dataset». https://huggingface.co/datasets/maharshipandya/spotify-tracks-dataset.
McKinney, Wes. 2010. «Data Structures for Statistical Computing in Python». En Proceedings of the 9th Python in Science Conference (SciPy 2010), editado por Stéfan van der Walt y Jarrod Millman. https://doi.org/10.25080/Majora-92bf1922-00a.
McKinney, Wes. 2022. Python for Data Analysis. 3.ª ed. O’Reilly Media.
New York City Taxi and Limousine Commission. 2024. «TLC Trip Record Data». https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page.
Okuta, Ryosuke, Yuya Unno, Daisuke Nishino, Shohei Hido, y Crissman Loomis. 2017. «CuPy: A NumPy-Compatible Library for NVIDIA GPU Calculations». Proceedings of the Workshop on Machine Learning Systems (LearningSys) in the Thirty-first Annual Conference on Neural Information Processing Systems (NIPS). http://learningsys.org/nips17/assets/papers/paper_16.pdf.
Polars developers. 2026. «Polars User Guide». https://docs.pola.rs/.
Python Software Foundation. 2026. «sqlite3DB-API 2.0 interface for SQLite databases». Python Software Foundation. https://docs.python.org/3/library/sqlite3.html.
Raasveldt, Mark, y Hannes Mühleisen. 2019. «DuckDB: an Embeddable Analytical Database». Proceedings of the 2019 International Conference on Management of Data (SIGMOD ’19) (New York, NY, USA), 1981-84. https://doi.org/10.1145/3299869.3320212.
Rocklin, Matthew. 2015. «Dask: Parallel Computation with Blocked algorithms and Task Scheduling». En Proceedings of the 14th Python in Science Conference (SciPy 2015), editado por Kathryn Huff y James Bergstra. https://doi.org/10.25080/Majora-7b98e3ed-013.
Roeschke, Matthew, y Patrick Hoefler. 2023. «PDEP-10: PyArrow as a required dependency for default string inference implementation». https://pandas.pydata.org/pdeps/0010-required-pyarrow-dependency.html.
The pandas development team. 2026. «pandas documentation: User Guide». https://pandas.pydata.org/docs/user_guide/.
Van den Bossche, Joris. 2024. «PDEP-14: Dedicated string data type for pandas 3.0». https://pandas.pydata.org/pdeps/0014-string-dtype.html.
Vink, Ritchie. 2021. «I Wrote One of the Fastest DataFrame Libraries». febrero 28. https://www.ritchievink.com/blog/2021/02/28/i-wrote-one-of-the-fastest-dataframe-libraries/.