Módulo 2: Anatomy Of An Iceberg Table

Los archivos de datos: el Parquet que ya conoces

Descripción

Esta lección abre el último eslabón de la cadena: el archivo de datos que el manifest file de la lección 4 enumera. Y, a diferencia de los tres eslabones anteriores, este no necesita ninguna herramienta especial de Iceberg para abrirse — es Parquet, exactamente el mismo formato columnar que ya usaste en data-engineering-foundations-guide, en spark-and-distributed-processing-guide, y en la lección 6 del módulo 1 de esta misma guía. Esta lección cierra el círculo que abrió la lección 1 del módulo 1: "Iceberg se sienta encima de Parquet, nunca lo reemplaza" — y aquí tienes la prueba directa, abriendo el archivo de datos real con pyarrow, sin pasar por PyIceberg en absoluto.

Conexión con el módulo. Las lecciones 2 a 4 recorrieron tres capas de indirección, cada una más especializada que la anterior. Esta lección llega, por fin, al final de la cadena — y el final resulta ser lo más familiar de todo el módulo.

Una analogía: las fotos concretas, sin ninguna capa más de indirección

Después de seguir la carátula, el índice de pruebas, y abrir la carpeta de evidencia correcta, finalmente tienes en la mano la foto en sí. No hay una capa más — es la evidencia, tal cual. Puedes mirarla, ampliarla, compararla con otra, sin tener que consultar ningún índice adicional. Eso es, con precisión, lo que un archivo de datos de Iceberg es: el final de la cadena, sin ninguna indirección adicional — un archivo Parquet normal, que cualquier herramienta que hable Parquet puede abrir, exactamente igual que abriría cualquier otro .parquet que hayas visto en este ecosistema.

Ejemplo trabajado: inspeccionando el archivo de datos, y después abriéndolo directamente

Paso 1 — table.inspect.files(): qué archivo de datos enumera el manifest file

# inspect_data_files.py
import os

import pyarrow.parquet as pq
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("=== table.inspect.files() -- una fila por archivo de datos ===")
files = table.inspect.files().select(
    ["file_path", "file_format", "record_count", "file_size_in_bytes"]
)
row = files.to_pylist()[0]
data_file_path = row["file_path"]
print("  file_path:", os.path.basename(data_file_path))
print("  file_format:", row["file_format"])
print("  record_count:", row["record_count"])
print("  file_size_in_bytes:", row["file_size_in_bytes"])
print("\ncantidad de archivos de datos:", files.num_rows)

Qué esperar (el nombre del archivo incluye un UUID asignado en tu corrida; record_count y el resto de los valores de negocio son deterministas):

=== table.inspect.files() -- una fila por archivo de datos ===
  file_path: 00000-0-<uuid>.parquet
  file_format: PARQUET
  record_count: 40
  file_size_in_bytes: 3273

cantidad de archivos de datos: 1

table.inspect.files() confirma explícitamente, en la columna file_format, algo que hasta ahora solo habías inferido de la extensión del archivo: PARQUET. record_count: 40 coincide, número por número, con table.scan().to_arrow().num_rows de la lección 7 del módulo 1 — el manifest file de la lección 4 apuntaba a exactamente este archivo, y este archivo contiene exactamente las cuarenta filas de la semana de Kiosko.

Paso 2 — Abre ese mismo archivo directamente con pq.read_table(), sin PyIceberg

print("\n=== Abriendo ESE MISMO archivo directamente con pq.read_table() ===")
local_path = data_file_path.replace("file://", "")
direct = pq.read_table(local_path)
print("num_rows:", direct.num_rows)
print("schema:")
print(direct.schema)
print("\nprimeras 3 filas, columnas seleccionadas:")
sample = direct.select(["order_id", "store_id", "product_id", "revenue"]).slice(0, 3).to_pylist()
for r in sample:
    print(" ", r)

Qué esperar:

=== Abriendo ESE MISMO archivo directamente con pq.read_table() ===
num_rows: 40
schema:
order_id: string not null
  -- field metadata --
  PARQUET:field_id: '1'
store_id: string not null
  -- field metadata --
  PARQUET:field_id: '2'
product_id: string not null
  -- field metadata --
  PARQUET:field_id: '3'
quantity: int32 not null
  -- field metadata --
  PARQUET:field_id: '4'
unit_price: double not null
  -- field metadata --
  PARQUET:field_id: '5'
revenue: double not null
  -- field metadata --
  PARQUET:field_id: '6'
order_ts: timestamp[us] not null
  -- field metadata --
  PARQUET:field_id: '7'

primeras 3 filas, columnas seleccionadas:
  {'order_id': 'ORD-1001', 'store_id': 'S01', 'product_id': 'P001', 'revenue': 1.65}
  {'order_id': 'ORD-1002', 'store_id': 'S01', 'product_id': 'P002', 'revenue': 1.2}
  {'order_id': 'ORD-1003', 'store_id': 'S02', 'product_id': 'P003', 'revenue': 1.5}

Detente en este resultado, porque es el punto central de toda la lección: pq.read_table() —la misma función de pyarrow.parquet que ya usaste en la lección 1 del módulo 1, sin ninguna dependencia de PyIceberg— abre este archivo sin ningún problema, y devuelve las cuarenta filas reales, con ORD-1001 y ORD-1002 reconocibles, exactamente como las escribiste. Este es el archivo de datos completo, sin ninguna capa de indirección adicional. Y fíjate en algo más, en el schema impreso: cada columna trae, en su metadata de Parquet, un PARQUET:field_id1 para order_id, 2 para store_id, y así sucesivamente. Estos son exactamente los mismos field_id que declaraste con NestedField en la lección 5 del módulo 1, y los mismos que leíste en schemas[0]["fields"] del archivo de metadata en la lección 3 de este módulo — Iceberg no inventa un mapeo nuevo en cada nivel de la cadena, reutiliza el mismo field_id desde el esquema declarado hasta el archivo Parquet físico.

Diagrama: el final de la cadena, sin ninguna capa más

flowchart LR
    A["catalogo\n(leccion 2)"] --> B["metadata.json\n(leccion 3)"]
    B --> C["manifest list\n(leccion 4)"]
    C --> D["manifest file\n(leccion 4)"]
    D --> E["archivo de datos\n00000-0-<uuid>.parquet\n(esta leccion)"]
    E -.->|"pq.read_table() directo,\nsin PyIceberg"| F["pyarrow.Table\n40 filas, mismo schema\nde siempre"]

Profundización: por qué esto importa para cualquier herramienta fuera de Iceberg

Vale la pena notar la consecuencia práctica de que el archivo final sea Parquet estándar, sin ninguna extensión propietaria: cualquier motor que sepa leer Parquet puede leer los datos crudos de una tabla Iceberg, sin entender absolutamente nada sobre catálogos, manifest lists o snapshots. Un script de pandas, un notebook de exploración rápida, o incluso otra guía completamente distinta de este ecosistema podrían abrir 00000-0-<uuid>.parquet directamente y obtener datos correctos — con una advertencia importante: esa lectura directa se salta toda la garantía que Iceberg existe para dar. Si la tabla tuviera varios archivos de datos, algunos de una escritura vieja ya reemplazada por un overwrite() (módulo 3), leer un archivo individual a mano podría mostrarte datos obsoletos o incompletos, sin ningún aviso — exactamente el mismo riesgo que ya nombró la lección 1 del módulo 1 sobre un Parquet suelto. La forma correcta de leer una tabla Iceberg siempre pasa por el catálogo y el snapshot vigente (table.scan().to_arrow()), que garantiza leer exactamente el conjunto de archivos que el snapshot actual declara como vigentes — nunca más, nunca menos. Esta lección abrió el archivo directamente solo con fines de inspección educativa, no como una práctica recomendada para leer datos de producción.

Errores comunes

Asumir que abrir el archivo de datos directamente es una forma válida de leer la tabla en producción. Qué pasa: alguien, después de ver en esta lección que pq.read_table() funciona sin problema, empieza a leer archivos Parquet individuales directamente en vez de pasar por table.scan(). Por qué pasa: funciona, técnicamente, en el caso simple de un solo archivo y un solo snapshot — el riesgo solo se hace visible cuando la tabla tiene más de un archivo o más de un snapshot. Cómo detectarlo: si tu código lee archivos con glob("*.parquet") sobre la carpeta data/ de una tabla Iceberg, en vez de usar table.scan(), revisa la Profundización de esta lección. Cómo corregirlo: usa siempre table.scan().to_arrow() (o las variantes con filtros que ya usaste en la lección 7 del módulo 1) para leer datos de una tabla Iceberg — esa es la única forma que respeta la garantía de "leer exactamente el snapshot vigente, ni más ni menos archivos".

Sorprenderse de que file_size_in_bytes (de table.inspect.files()) y el tamaño real en disco no coincidan exactamente al byte. Qué pasa: alguien compara file_size_in_bytes contra el tamaño que ls -la muestra para el mismo archivo, y encuentra una diferencia mínima, y se preocupa pensando en corrupción. Por qué pasa: en la mayoría de los casos ambos números coinciden exactamente —como en el ejemplo de esta lección—, pero pueden diferir si el sistema de archivos redondea el tamaño reportado a bloques, o si hay alguna diferencia de captura entre el momento de la escritura y el momento de la consulta. Cómo detectarlo: si la diferencia es de solo unos pocos bytes o coincide con el tamaño de bloque de tu sistema de archivos, no es corrupción. Cómo corregirlo: no hay nada que corregir en el caso típico — si quieres el tamaño exacto y confiable de un archivo, file_size_in_bytes (que viene registrado en el propio manifest file en el momento de la escritura) es la fuente correcta, más confiable que confiar en el sistema de archivos después del hecho.

No relacionar el PARQUET:field_id visto en esta lección con el field_id de la lección 5 del módulo 1. Qué pasa: alguien ve PARQUET:field_id: '1' en el schema impreso por pq.read_table() y lo trata como un detalle interno de Parquet sin conexión con nada más. Por qué pasa: aparece en un formato de impresión distinto (metadata de campo de PyArrow) al de NestedField(field_id=1, ...) que ya viste. Cómo detectarlo: si no puedes explicar por qué order_id tiene field_id: 1 tanto en el archivo Parquet como en el archivo de metadata JSON, revisa la Profundización de la lección 5 del módulo 1 y el paso 2 del ejemplo trabajado de esta lección lado a lado. Cómo corregirlo: es exactamente el mismo número, propagado por Iceberg desde el Schema que declaraste hasta el propio archivo Parquet físico — es la pieza que hace posible que un RENAME COLUMN (módulo 4) no tenga que reescribir ningún archivo de datos: el field_id embebido en el Parquet nunca cambia, solo cambia la etiqueta con la que el catálogo lo presenta.

Ejercicios

Ejercicio 1 — Reproduce ambos pasos tú mismo. En tu propia máquina, con el estado del módulo 1 disponible, corre el script completo de esta lección. Confirma que record_count (de table.inspect.files()) y num_rows (de pq.read_table() directo) son ambos 40.

Ver solución

Si tu tabla tiene el único archivo de datos que dejó el módulo 1, ambos números deberían ser 40, sin ninguna diferencia — son, literalmente, dos formas distintas de contar las filas del mismo archivo físico: una a través de la metadata que Iceberg registró en el momento de la escritura, otra leyendo el archivo directamente y contando de verdad. Que coincidan es la confirmación de que la metadata de Iceberg es fiel al contenido real del archivo.

Ejercicio 2 — Compara table.scan().to_arrow() contra pq.read_table() directo, columna por columna. Usando el estado del módulo 1, corre table.scan().to_arrow().schema (la forma correcta de leer, vía Iceberg) y compáralo contra direct.schema del paso 2 de esta lección (la lectura directa del archivo). ¿Encuentras alguna diferencia?

Ver solución

No debería haber ninguna diferencia visible en este punto de la guía —mismas siete columnas, mismos tipos, mismos field_id—, porque solo existe un archivo de datos y un snapshot; en este caso, leer "vía Iceberg" y leer "el archivo directamente" producen exactamente el mismo resultado. La diferencia real aparecería recién si la tabla tuviera más de un archivo de datos con distintas versiones de esquema (después de una evolución de esquema, módulo 4) o con archivos de una escritura ya reemplazada (después de un overwrite(), módulo 3) — ahí, table.scan() filtraría correctamente solo los archivos vigentes del snapshot actual, mientras que leer archivos sueltos a mano podría mezclar datos de distintas versiones sin ningún aviso.

Ejercicio 3 — Predicción: ¿qué pasaría si abrieras un manifest file (.avro) con pq.read_table(), como si fuera Parquet? Sin probarlo todavía, predice: si le pasaras la ruta de un manifest file (*-m0.avro) a pq.read_table() en vez de la ruta de un archivo de datos real, ¿qué esperas que pase?

Ver solución

Debería fallar con un error —algo relacionado con un formato de archivo inválido o un "magic number" incorrecto—, porque pq.read_table() espera, específicamente, el formato binario de Parquet, que empieza y termina con los bytes mágicos PAR1; un archivo Avro tiene su propia estructura binaria completamente distinta (empieza con los bytes Obj\x01, visible en la lección 6 de este módulo). Este es el mismo error, en espíritu, que cometerías si intentaras abrir una imagen .png con un lector de .jpg — ambos son binarios, pero con estructuras internas incompatibles. La lección correcta para leer un manifest file es table.inspect.manifests() (lección 4), nunca pq.read_table().

Resumen y siguiente paso

En esta lección abriste el eslabón final de la cadena: el archivo de datos que el manifest file de la lección 4 enumera, resultó ser exactamente el mismo Parquet que ya conoces —file_format: PARQUET, record_count: 40—, y lo abriste directamente con pq.read_table(), sin ninguna dependencia de PyIceberg, confirmando que el field_id embebido en el Parquet coincide, número por número, con el declarado en la lección 5 del módulo 1.

Antes de avanzar deberías poder: explicar por qué el archivo de datos final no necesita ninguna herramienta especial de Iceberg para abrirse; y explicar el riesgo de leer archivos de datos directamente en vez de pasar por table.scan().

Recorriste la cadena completa, un eslabón a la vez: catálogo → metadata → manifest list → manifest files → archivos de datos. La lección 6 repite este mismo recorrido, pero ahora completo, desde la terminal, con comandos de shell reales —para que veas, con tus propios ojos, la diferencia entre lo legible y lo binario, sin la ayuda de ningún script de Python.

Recursos

  • Apache Parquet — documentación oficial del formato de archivo, la misma referencia que ya usaste en las seis guías anteriores del ecosistema. parquet.apache.org/docs/. En inglés.
  • PyIceberg — referencia de API, table.inspect.files() y las columnas exactas que devuelve, incluidas las métricas por columna (readable_metrics). py.iceberg.apache.org/api. En inglés.
  • Apache Iceberg — documentación oficial, "Table Spec", sección de "Data Files", la definición formal de qué debe registrar un DataFile dentro de un manifest. iceberg.apache.org/spec. En inglés.
  • DISEÑO de esta guía — la afirmación central de M1L1 ("Iceberg se sienta encima de Parquet, nunca lo reemplaza"), confirmada aquí con código real. src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.