Módulo 5: Hidden Partitioning And Partition Evolution
Partición oculta: la misma consulta, sin saber el layout
Descripción
La lección 2 te dejó con un archivero real, en disco, y una lección incómoda: para leerlo bien hay que declararle, a cada lector, cuál es la convención de carpetas. Esta lección hace la pregunta contraria: ¿qué pasa si le haces la misma pregunta de negocio —"dame los datos de S01"— a una tabla Iceberg? Vas a usar kiosko.fact_orders, la tabla que ya existe desde el módulo 1 de esta guía, para responderla — sin crear ninguna tabla nueva todavía, sin tocar partición todavía (esa tabla, como confirmó el módulo 2, tiene un PartitionSpec completamente vacío). Eso es, precisamente, lo que hace contundente el resultado: la sintaxis que vas a usar aquí es exactamente la misma que vas a usar en la lección 5 contra una tabla particionada de verdad.
Conexión con el módulo. Esta lección es la bisagra del módulo: la lección 2 mostró el costo de la carpeta visible; esta lección muestra la ausencia de ese costo del lado de Iceberg. Las lecciones 4 a 7 construyen la partición real que hace que esa ausencia de costo también sea, además, rápida — pero la forma de preguntar ya no va a cambiar ni una línea a partir de aquí.
Una analogía: el cartero, otra vez, antes incluso de tener sacos
Retomando al cartero de la lección 1: esta lección es el momento anterior a que el cartero organice nada. Todavía no tiene sacos por residente ni por día — solo tiene un montón de correspondencia, sin ningún orden físico particular. Y aun así, cuando le pides "la correspondencia de Ana", él sabe encontrarla — revisa lo que tiene, filtra por el nombre que le diste, y te entrega el resultado correcto. No es tan rápido como sería con sacos organizados —tiene que revisar más de lo estrictamente necesario—, pero la forma de pedírselo ya es, desde este momento, la definitiva: nunca vas a tener que decirle "revisa el saco 3". Eso es exactamente lo que estás por comprobar con kiosko.fact_orders.
Ejemplo trabajado: la misma pregunta, sin ninguna carpeta
Paso 1 — Carga la tabla, y confirma que no tiene ninguna partición
# l3_hidden_partitioning_demo.py
import os
from pyiceberg.catalog import load_catalog
warehouse_path = os.path.abspath("kiosko_warehouse")
catalog_db_path = os.path.abspath("kiosko_catalog.db")
catalog = load_catalog(
"kiosko", type="sql",
uri=f"sqlite:///{catalog_db_path}", warehouse=f"file://{warehouse_path}",
)
table = catalog.load_table("kiosko.fact_orders")
print("kiosko.fact_orders -- PartitionSpec vigente:", table.spec())
Qué esperar (verificado corriendo el script real, con kiosko.fact_orders del módulo 1 ya cargada):
kiosko.fact_orders -- PartitionSpec vigente: []
Confirmado, otra vez: [], exactamente lo que el módulo 2 (lección 3) ya había mostrado. No hay ningún PartitionField — no hay ninguna gaveta física distinta de "todos los archivos juntos".
Paso 2 — La consulta oculta: filtra por store_id, sin mencionar ningún archivo
# la MISMA sintaxis que vas a usar en la leccion 5 contra fact_orders_at_scale,
# particionada de verdad -- el codigo de esta consulta nunca menciona una carpeta
s01 = table.scan(row_filter="store_id == 'S01'").to_arrow()
s01_revenue = sum(s01.column("revenue").to_pylist())
print(f"row_filter=\"store_id == 'S01'\" -> {s01.num_rows} filas, revenue={round(s01_revenue, 2)}")
Qué esperar:
row_filter="store_id == 'S01'" -> 16 filas, revenue=38.3
16 filas, 38.3 de revenue — el mismo desglose de S01 que ya conoces desde data-engineering-foundations-guide. Y fíjate en lo que no hay en esta llamada: ningún path, ningún nombre de carpeta, ningún argumento partitioning="hive". row_filter="store_id == 'S01'" es una expresión sobre el valor de una columna de negocio, punto — la misma clase de predicado que usarías en un WHERE de SQL, sin ninguna referencia a cómo están organizados los archivos por dentro.
Paso 3 — Cuenta cuántos archivos tocó el scan, aunque todavía no haya nada que podar
plan = list(table.scan(row_filter="store_id == 'S01'").plan_files())
print(f"archivos de datos que el scan realmente toco: {len(plan)}")
Qué esperar:
archivos de datos que el scan realmente toco: 1
Un solo archivo — porque kiosko.fact_orders, con sus cuarenta filas, nunca tuvo más que uno. Sin partición, no hay nada que podar todavía: cualquier consulta, filtrada o no, toca el único archivo que existe. Esto no es un defecto del ejemplo — es, precisamente, la razón por la que esta lección usa una tabla sin partición: aísla la pregunta "¿cambia la sintaxis de la consulta?" (respuesta: no) de la pregunta "¿cambia el rendimiento?" (respuesta: sí, y la lección 5 lo va a medir con una tabla que sí tiene archivos que podar).
Diagrama: la misma llamada, dos tablas distintas
flowchart TB
Q["table.scan(row_filter=\"store_id == 'S01'\").to_arrow()"]
Q -->|"contra kiosko.fact_orders\n(sin particion, esta leccion)"| A["1 archivo tocado de 1 total\nnada que podar todavia"]
Q -->|"contra kiosko.fact_orders_at_scale\n(particionada, leccion 5)"| B["1 archivo tocado de 3 totales\npoda real, medible"]
A -.->|"MISMA linea de codigo\nen ambos casos"| B
Profundización: quién decide el layout, ahora
La lección 2 dejó una pregunta abierta: si el conocimiento del layout ya no vive en cada lector, ¿dónde vive? La respuesta, con esta tabla como evidencia mínima, es: en la propia tabla, no en el lector ni en el escritor. Cuando alguien escribió table.append() sobre kiosko.fact_orders en el módulo 1, no tuvo que decidir "voy a organizar esto por store_id" — esa decisión, si existiera, estaría en el PartitionSpec de la tabla, consultado automáticamente en cada append() y en cada scan(). Como el spec está vacío, no hay ninguna decisión que tomar — pero el mecanismo es idéntico al que vas a ver en la lección 5, con un spec real: ni quien escribe ni quien lee necesita repetir, en su propio código, cuál es el criterio de partición. Esa es la definición precisa y completa de partición oculta: no que la partición no exista, sino que su existencia (o su ausencia, como en este caso) es transparente para cualquier código que consulte o escriba la tabla, más allá del filtro de negocio que ya escribirías de todos modos.
Errores comunes
Pensar que row_filter necesita saber, de antemano, si la tabla está particionada por esa columna. Qué pasa: alguien, al escribir row_filter="store_id == 'S01'" contra una tabla sin partición, espera que falle o que se comporte distinto que contra una tabla particionada. Por qué pasa: si vienes de sistemas donde filtrar por una columna de partición requiere sintaxis especial (como el partitioning="hive" explícito de la lección 2), es natural esperar algo parecido aquí. Cómo detectarlo: si tu código cambia según si la tabla que consultas está particionada o no, revisa esta lección — el paso 2 y el paso 3 de la lección 5 usan la línea de código idéntica. Cómo corregirlo: row_filter siempre acepta cualquier expresión sobre columnas del esquema, esté la tabla particionada o no — la única diferencia observable es cuántos archivos toca el plan_files() por debajo, nunca la sintaxis de arriba.
Confundir plan_files() con to_arrow(), y esperar que ambos devuelvan el mismo tipo de resultado. Qué pasa: alguien intenta iterar sobre table.scan(...).plan_files() esperando ver filas de datos, y se sorprende al ver objetos de tarea de escaneo (FileScanTask) en vez de las filas de S01. Por qué pasa: ambos métodos cuelgan del mismo scan(), así que es fácil asumir que hacen lo mismo con distinto nombre. Cómo detectarlo: si tu código espera columnas de negocio (store_id, revenue) al iterar plan_files(), revisa el paso 3 de esta lección — ahí solo se cuenta len(plan), nunca se leen filas de ese resultado. Cómo corregirlo: to_arrow() (o to_pandas(), to_pylist()) materializa las filas que cumplen el filtro; plan_files() devuelve la lista de archivos que el motor decidió que necesita abrir para responder esa consulta — es la pieza que te deja medir, con un número, cuánta poda hizo el scan.
Ejercicios
Ejercicio 1 — Reproduce la consulta oculta tú mismo, contra kiosko.fact_orders. Con la tabla del módulo 1 disponible, corre los tres pasos de esta lección. Confirma PartitionSpec vigente: [], 16 filas y 38.3 de revenue para S01, y 1 archivo tocado.
Ver solución
Si tu catálogo tiene kiosko.fact_orders cargada exactamente como la dejó el módulo 1, tu salida debería coincidir con la de esta lección en los tres números: [] para el spec, 16/38.3 para la consulta de S01, y 1 archivo en plan_files(). Si el conteo de filas o el revenue no coinciden, revisa si tu tabla tiene las cuarenta filas completas y correctas del módulo 1.
Ejercicio 2 — Filtra por product_id en vez de store_id, y confirma que la sintaxis no cambia. Escribe table.scan(row_filter="product_id == 'P002'").to_arrow() contra la misma tabla, y confirma cuántas filas y qué revenue obtienes.
Ver solución
p002 = table.scan(row_filter="product_id == 'P002'").to_arrow()
p002_revenue = sum(p002.column("revenue").to_pylist())
print(f"P002: {p002.num_rows} filas, revenue={round(p002_revenue, 2)}")
El resultado son 9 filas y un revenue que puedes verificar sumando las líneas de P002 en la semana real de Kiosko. El punto de este ejercicio no es el número en sí, sino confirmar que row_filter acepta cualquier columna del esquema con la misma sintaxis exacta —no hay ningún tratamiento especial para store_id frente a product_id, ni aquí ni en ninguna tabla Iceberg, particionada o no.
Ejercicio 3 — Predicción: ¿qué pasaría si intentaras filtrar por una columna que no existe? Sin correrlo, predice: si escribieras table.scan(row_filter="region == 'LATAM'") —una columna que kiosko.fact_orders nunca tuvo—, ¿esperas que devuelva cero filas en silencio, o que falle con un error?
Ver solución
Falla con un error explícito —típicamente algo relacionado con que la columna region no existe en el esquema de la tabla—, no devuelve cero filas en silencio. Esto es una diferencia importante frente al error silencioso que viste en la lección 2, donde store_id simplemente desaparecía sin ningún aviso: PyIceberg valida el row_filter contra el esquema real de la tabla —el mismo esquema que el catálogo conoce en todo momento—, así que un nombre de columna equivocado se detecta de inmediato, en vez de producir un resultado vacío que alguien podría interpretar, por error, como "no hay datos de LATAM".
Resumen y siguiente paso
En esta lección hiciste la misma pregunta de negocio que la lección 2 —"dame los datos de S01"— contra una tabla Iceberg, sin partición todavía, y confirmaste que la sintaxis nunca menciona un archivo ni una carpeta: table.scan(row_filter="store_id == 'S01'"). Verificaste, con plan_files(), que hoy no hay nada que podar —un solo archivo, tocado siempre—, y dejaste establecido que esa misma línea de código es la que vas a reutilizar, sin ningún cambio, contra una tabla particionada de verdad.
Antes de avanzar deberías poder: escribir un row_filter sobre cualquier columna del esquema; explicar la diferencia entre to_arrow() y plan_files(); y anticipar que la lección 5 va a repetir esta misma consulta, pero esta vez con evidencia real de poda de archivos.
Antes de repetir esa consulta contra una tabla particionada, necesitas vocabulario preciso sobre cómo se decide el layout de una partición Iceberg. La lección 4 te da los tres transforms que vas a usar en el resto de este módulo.
Recursos
- PyIceberg — referencia de API, sintaxis exacta de
table.scan(row_filter=...)ytable.scan(...).plan_files(). py.iceberg.apache.org/api. En inglés. - Apache Iceberg — documentación oficial, "Partitioning", sección "Iceberg's hidden partitioning" (la definición formal que esta lección demuestra con código). iceberg.apache.org/docs/latest/partitioning. En inglés.
- DISEÑO de esta guía — la sección del módulo 5, con la regla explícita de que la consulta nunca debe mencionar la estructura física.
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.