Módulo 3: Snapshots And Time Travel

Cambiando P002 con un overwrite normal

Descripción

Esta es la lección donde ocurre el cambio. kiosko.dim_product tiene, desde la lección 2, una fila por producto con los valores V1P002 en category='snacks', unit_cost=0.60'—. Esta lección sobrescribe la tabla completa con los valores V2: los mismos cuatro productos, pero P002 ahora en category='health-snacks', unit_cost=0.68. La herramienta es table.overwrite() — una operación normal de Iceberg, sin ningún parámetro especial de "modo historia". Y vas a descubrir, con evidencia real, que "un overwrite" no siempre significa "un snapshot".

Conexión con el módulo. La lección 2 dejó snap_v1 capturado: la foto de dim_product con P002 todavía en su estado original. Esta lección es la segunda mitad del experimento —la escritura que hace que el estado vigente de la tabla cambie—. Sin esta lección, no habría ningún "antes" y "después" que viajar entre sí; con ella, las lecciones 4 a 6 tienen algo real que recuperar.

Una analogía: reponer el estante por completo

Vuelve al encargado del supermercado. Reponer el estante no significa agregar mercadería nueva al lado de la vieja —eso sería append(), y produciría un estante con el doble de productos, la mayoría duplicados—. Reponer significa retirar todo lo que había y poner la mercadería nueva en su lugar: el estante, después de reponer, tiene la misma cantidad de espacios que antes, pero con contenido actualizado. Esta lección hace exactamente eso con kiosko.dim_product: retira las cuatro filas de V1 y pone las cuatro filas de V2 en su lugar — el mismo número de productos, uno de ellos con datos distintos.

Ejemplo trabajado: el overwrite, y lo que revela table.history()

Paso 1 — Los valores V2: P002 cambia a health-snacks/0.68

# overwrite_v2.py -- kiosko.dim_product, el cambio de P002
import os

import pyarrow as pa
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.dim_product")

dim_product_pa_schema = pa.schema([
    pa.field("product_id", pa.string(), nullable=False),
    pa.field("product_name", pa.string(), nullable=False),
    pa.field("category", pa.string(), nullable=False),
    pa.field("unit_cost", pa.float64(), nullable=False),
])

# V2 -- vigente desde 2026-08-15; el unico cambio es P002. P001/P003/P004 identicos a V1
DIM_PRODUCT_V2 = [
    {"product_id": "P001", "product_name": "Bottled Water 600ml", "category": "beverages", "unit_cost": 0.40},
    {"product_id": "P002", "product_name": "Energy Bar", "category": "health-snacks", "unit_cost": 0.68},
    {"product_id": "P003", "product_name": "Instant Coffee Sachet", "category": "beverages", "unit_cost": 0.35},
    {"product_id": "P004", "product_name": "Phone Charger Cable", "category": "electronics", "unit_cost": 2.10},
]

pa_table_v2 = pa.Table.from_pylist(DIM_PRODUCT_V2, schema=dim_product_pa_schema)
table.overwrite(pa_table_v2)

print("table.scan().to_arrow() tras el overwrite:")
for row in table.scan().to_arrow().to_pylist():
    print(f"  {row['product_id']}  {row['product_name']:<22} {row['category']:<14} unit_cost={row['unit_cost']}")

Qué esperar (verificado corriendo el script real, sobre la tabla que dejó la lección 2):

table.scan().to_arrow() tras el overwrite:
  P001  Bottled Water 600ml    beverages      unit_cost=0.4
  P002  Energy Bar             health-snacks  unit_cost=0.68
  P003  Instant Coffee Sachet  beverages      unit_cost=0.35
  P004  Phone Charger Cable    electronics    unit_cost=2.1

Cuatro filas, exactamente como antes del overwrite() — el grano no cambió—, pero P002 ahora tiene category='health-snacks', unit_cost=0.68. P001, P003 y P004 son idénticos a V1: el overwrite() reemplazó toda la tabla, no solo la fila que cambió, así que fue necesario incluir las tres filas sin cambios en DIM_PRODUCT_V2 para que sobrevivieran a la operación.

Paso 2 — table.history() revela algo que la lección 1 no adelantó del todo

history = table.history()
print(f"\ntable.history() tiene {len(history)} entradas")

snaps = table.inspect.snapshots().select(["snapshot_id", "parent_id", "operation", "summary"])
print("\ntable.inspect.snapshots():")
for row in snaps.to_pylist():
    summary = dict(row["summary"])
    print(f"  operation={row['operation']:<8}  "
          f"added-records={summary.get('added-records', '-')}  "
          f"deleted-records={summary.get('deleted-records', '-')}  "
          f"total-records={summary['total-records']}")

Qué esperar (verificado corriendo el script real; los valores exactos de snapshot_id, parent_id y committed_at son de tu propia corrida, distintos cada vez — la cantidad de entradas y los valores de operation/summary son deterministas):

table.history() tiene 3 entradas

table.inspect.snapshots():
  operation=append    added-records=4  deleted-records=-  total-records=4
  operation=delete    added-records=-  deleted-records=4  total-records=0
  operation=append    added-records=4  deleted-records=-  total-records=4

Tres entradas, no dos. Esta es la parte de esta lección que el mapa de la lección 1 dejó, a propósito, sin detallar del todo. La primera entrada es snap_v1 —el append() de la lección 2, con las cuatro filas de V1—. La tercera es el snapshot vigente ahora, con las cuatro filas de V2. Pero en el medio hay una tercera entrada: una operación delete que borró las cuatro filas de V1 (deleted-records=4, total-records=0) antes de que la siguiente entrada las reemplazara con las de V2.

Por qué table.overwrite() puede producir más de un snapshot

Esto no es un error de esta guía ni un comportamiento inesperado — está documentado en la propia API de PyIceberg. El docstring de Table.overwrite(), en la versión 0.11.1, dice exactamente esto:

"An overwrite may produce zero or more snapshots based on the operation: DELETE (existing Parquet files can be dropped completely), OVERWRITE (existing Parquet files need to be rewritten to drop rows that match the overwrite filter), APPEND (new data is being inserted into the table)."

En el caso de esta lección —un overwrite() sin overwrite_filter, que por defecto reemplaza el 100% de las filas existentes—, Iceberg resolvió la operación como dos pasos internos: un delete que retira todos los archivos de datos viejos completos (más barato que reescribirlos fila por fila, porque ningún archivo sobrevive parcialmente), seguido de un append que agrega los archivos nuevos con V2. Cada uno de esos dos pasos es, por derecho propio, un snapshot — exactamente la misma regla de la lección 2 de este módulo ("cada escritura de datos es un snapshot nuevo"), aplicada aquí dos veces dentro de una sola llamada a overwrite().

Fíjate en un detalle más, verificado en el mismo table.inspect.snapshots(): el snapshot intermedio —el delete— tiene total-records=0. Durante una fracción de ese commit, kiosko.dim_product no tuvo, literalmente, ninguna fila. Nadie que consulte la tabla desde afuera ve nunca ese estado vacío como "vigente" —table.overwrite() corre como una única transacción atómica, y el catálogo solo actualiza su puntero una vez, al final, hacia el snapshot con V2—, pero ese snapshot intermedio sigue quedando archivado en el historial, disponible para quien lo busque explícitamente con su propio snapshot_id. Es, en la práctica más literal, una foto real del estante completamente vacío, tomada y archivada, aunque nadie la haya visto en el mostrador mientras pasaba.

Diagrama: dos escrituras, tres snapshots

flowchart LR
    S1["snap_v1\noperation: append\nP001..P004, V1\n(P002 = snacks/0.60)"] -->|"table.overwrite(V2)\nparte 1: borra TODO"| SD["snapshot intermedio\noperation: delete\n0 filas -- nunca 'vigente'\npara nadie de afuera"]
    SD -->|"parte 2: agrega V2"| S2["snap_v2\noperation: append\nP001..P004, V2\n(P002 = health-snacks/0.68)\n= current_snapshot()"]

    S1 -.->|"snap_v1 sigue disponible"| T["time travel\n(leccion 5)"]

Errores comunes

Asumir que table.history() va a tener exactamente una entrada nueva por cada llamada a un método de escritura. Qué pasa: alguien, después de haber corrido dos operaciones —append(V1) en la lección 2, overwrite(V2) en esta lección—, espera ver len(table.history()) == 2, y se sorprende al ver 3. Por qué pasa: es una generalización razonable a partir de la lección 2, donde "una escritura, un snapshot" fue literalmente cierto — pero esa lección no cubrió el caso de un overwrite() completo, que la Profundización de esta lección explica que puede requerir más de un snapshot internamente. Cómo detectarlo: si tu conteo de snapshots no coincide con tu conteo de llamadas a métodos de escritura, revisa table.inspect.snapshots() con la columna operation — vas a encontrar la operación extra ahí, documentada, no perdida. Cómo corregirlo: cuenta snapshots consultando table.history() o table.inspect.snapshots() directamente, nunca asumiendo un número a partir de cuántas veces llamaste a un método — el número real de snapshots depende de cómo Iceberg decide resolver esa operación por dentro, no solo de tu código.

Preocuparse por el snapshot intermedio con total-records=0, pensando que la tabla "se rompió a la mitad". Qué pasa: alguien ve la fila operation=delete ... total-records=0 en table.inspect.snapshots() y se alarma, pensando que hubo un momento real en que kiosko.dim_product estuvo vacía y alguien pudo haberla consultado así. Por qué pasa: ver "0 filas" en medio de una operación suena, por instinto, a una falla de atomicidad — exactamente el problema que el overwrite-partition de data-engineering-foundations-guide (módulo 6) sí tenía, por no ser transaccional. Cómo detectarlo: si te preocupa que un lector externo pudiera haber visto la tabla vacía durante este overwrite(), revisa qué garantiza "ACID" en este contexto — tema que el módulo 4 de esta guía desarrolla a fondo. Cómo corregirlo: no hay nada que corregir — table.overwrite() corre como una sola transacción; el catálogo nunca apunta al snapshot intermedio como vigente, así que ningún lector externo pudo haberlo visto. El snapshot con total-records=0 queda archivado en el historial por completitud —es una foto real, tomada y guardada—, pero nunca fue la foto que el mostrador mostró.

Ejercicios

Ejercicio 1 — Reproduce el overwrite tú mismo, y confirma las tres entradas. Con el estado de la lección 2 disponible (snap_v1 capturado), corre el overwrite_v2.py de esta lección. Confirma que table.scan().to_arrow() muestra P002 como health-snacks/0.68, y que table.history() reporta exactamente tres entradas.

Ver solución

Si tu tabla partió del estado exacto de la lección 2 —cuatro filas con V1, un solo snapshot—, tu salida debería coincidir con la de esta lección: las cuatro filas de V2 en el scan(), y tres entradas en table.history(), con operation en el orden append, delete, append. Los snapshot_id van a ser distintos de los mostrados aquí — eso es exactamente lo esperado.

Ejercicio 2 — Calcula cuántos archivos de datos Parquet existen físicamente en kiosko_warehouse/kiosko/dim_product/data/ después de esta lección. Usando table.inspect.files() o revisando la carpeta data/ directamente desde la terminal, cuenta cuántos archivos .parquet distintos hay. Justifica tu respuesta pensando en qué archivos sobrevivieron al delete y cuáles son nuevos de append.

Ver solución

Dos archivos: el que contiene las cuatro filas de V1 (creado en la lección 2) y el que contiene las cuatro filas de V2 (creado por la parte append de esta lección). El snapshot delete de en medio no borra ningún archivo físico — Iceberg nunca borra un archivo de datos por una operación de escritura normal; lo que hace el delete es dejar de referenciar ese archivo desde el snapshot vigente. El archivo Parquet de V1 sigue existiendo en disco, íntegro, porque snap_v1 todavía lo necesita para responder correctamente a un table.scan(snapshot_id=snap_v1) — lo vas a comprobar en la lección 5. Solo una operación de mantenimiento explícita (remove_orphan_files, que el módulo 7 de esta guía enseña) borraría ese archivo, y únicamente después de que ningún snapshot vigente lo necesite.

Ejercicio 3 — Predicción: ¿qué pasaría si usaras table.append() en vez de table.overwrite() para este mismo cambio de P002? Sin correrlo, predice: si en vez del overwrite_v2.py de esta lección hubieras corrido table.append(pa_table_v2) —agregando las cuatro filas de V2 sin retirar las de V1—, ¿cuántas filas totales tendría kiosko.dim_product, y cuántas filas con product_id='P002' existirían?

Ver solución

Ocho filas totales, dos de ellas con product_id='P002' —una con category='snacks'/unit_cost=0.60 (de V1) y otra con category='health-snacks'/unit_cost=0.68 (de V2)—, coexistiendo en el mismo snapshot vigente. Esto rompería el grano de dim_product —una fila por producto—, y cualquier JOIN posterior contra fact_orders produciría un fan-out: cada orden de P002 se multiplicaría por dos filas de la dimensión, duplicando el revenue reportado. Este es exactamente el error que el segundo punto de "Errores comunes" de la lección 2 de este módulo ya advirtió, y la razón concreta por la que esta lección usa table.overwrite() — reemplazar el contenido completo, no sumarle una versión más.

Resumen y siguiente paso

En esta lección cambiaste P002 de snacks/0.60 a health-snacks/0.68 con table.overwrite(), un overwrite() normal de Iceberg, sin ningún parámetro de "modo historia". Confirmaste, con table.scan().to_arrow(), que el estado vigente de kiosko.dim_product refleja V2. Y descubriste, con table.inspect.snapshots(), que esa única llamada a overwrite() produjo tres entradas en el historial —append, delete, append—, no dos, porque Iceberg resolvió el reemplazo completo como un retiro seguido de una carga.

Antes de avanzar deberías poder: explicar la diferencia entre table.append() y table.overwrite() en el contexto de una tabla de dimensión; y explicar por qué table.history() puede tener más entradas que llamadas a métodos de escritura hiciste, citando el docstring oficial de overwrite() como evidencia.

kiosko.dim_product ahora tiene tres snapshots archivados, y su estado vigente ya no coincide con V1. La lección 4 se detiene en el snapshot_id capturado en la lección 2 —snap_v1— y en la regla dura de esta guía sobre nunca hardcodearlo, antes de usarlo para viajar en el tiempo en la lección 5.

Recursos

  • PyIceberg — referencia de API, table.overwrite(), incluido el docstring citado en esta lección sobre las combinaciones de operaciones que puede producir. py.iceberg.apache.org/api. En inglés.
  • PyIceberg — referencia de API, table.inspect.snapshots(), con las columnas operation y summary usadas en esta lección para distinguir append de delete. py.iceberg.apache.org/api. En inglés.
  • Apache Iceberg — documentación oficial, "Table Spec", sección de "Snapshots", donde operation se define como parte formal del summary de cada snapshot. iceberg.apache.org/spec. En inglés.
  • DISEÑO de data-modeling-for-analytics-guide — fuente del cambio canónico de P002 (snacks/0.60health-snacks/0.68, 2026-08-15) que esta lección reproduce con table.overwrite(). src/guides/data-modeling-for-analytics-guide/DISENO.md. En español.
  • DISEÑO de esta guía — la sección "Snapshots y time travel" (M3), fuente exacta de este paso del experimento. src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.