Módulo 3: Snapshots And Time Travel
Lo que el time travel no reemplaza
Descripción
Esta es la lección más importante de este módulo, y existe a propósito para que no salgas de él con una idea equivocada. La lección 6 recuperó el margen correcto de P002 —10.8— con time travel, sin ninguna columna de historia, y eso es un resultado real y verificado. Pero no significa que el time travel resuelva, en general, el problema que data-modeling-for-analytics-guide resolvió con valid_from/valid_to a nivel de fila. Esta lección construye un ejemplo aislado, ejecutado de verdad, que muestra exactamente dónde está el límite — y por qué el caso de Kiosko cayó, sin que nadie lo forzara, del lado fácil de ese límite.
Conexión con el módulo. Las lecciones 2 a 6 mostraron el camino favorable: una tabla, un único cambio, todos los hechos de un mismo lado de ese cambio. Esta lección examina qué pasa cuando esas condiciones no se cumplen, con un experimento real —no una advertencia en abstracto— que vas a poder correr tú mismo.
Una analogía: pedir "la foto del martes" cuando el estante cambió dos veces esa semana
Vuelve, una última vez, al archivo de fotos del supermercado. Si el estante se reabasteció una sola vez en toda la semana, "pedir la foto de antes del reabastecimiento" es una pregunta sin ambigüedad — hay una sola foto que responde correctamente para cualquier día de esa semana. Pero si el estante se reabasteció dos veces —el martes y el jueves, por ejemplo—, y te preguntan "¿qué había en el estante el lunes, y qué había el viernes, en la misma consulta?", ninguna foto individual del archivo responde ambas preguntas a la vez. La foto de antes del martes es correcta para el lunes, pero incorrecta para el viernes; la foto de después del jueves es correcta para el viernes, pero incorrecta para el lunes. Necesitarías dos fotos distintas, una para cada pregunta — y el archivo, por sí solo, no sabe cuál corresponde a cuál sin que tú se lo digas, fila por fila.
El experimento: dos ventas, dos costos correctos, un solo snapshot_id
Esta ilustración es deliberadamente aislada del modelo de Kiosko —usa un catálogo y un warehouse propios, descartables, creados y destruidos dentro de esta misma lección— para no mezclar datos hipotéticos con las tablas reales que el resto de esta guía construye. El objetivo es mostrar, con código real, el límite estructural del time travel, no agregar un producto nuevo a Kiosko.
Paso 1 — Un producto de juguete, con dos escrituras de precio
# toy_limit_illustration.py -- ilustracion aislada, NO parte del modelo de Kiosko
import os
import shutil
import pyarrow as pa
from pyiceberg.catalog import load_catalog
from pyiceberg.schema import Schema
from pyiceberg.types import DoubleType, NestedField, StringType
demo_warehouse = os.path.abspath("demo_limit_warehouse")
demo_db = os.path.abspath("demo_limit_catalog.db")
os.makedirs(demo_warehouse, exist_ok=True)
demo_catalog = load_catalog(
"demo", type="sql",
uri=f"sqlite:///{demo_db}", warehouse=f"file://{demo_warehouse}",
)
demo_catalog.create_namespace("demo")
price_schema = Schema(
NestedField(field_id=1, name="product_id", field_type=StringType(), required=True),
NestedField(field_id=2, name="unit_cost", field_type=DoubleType(), required=True),
)
price = demo_catalog.create_table("demo.toy_price", schema=price_schema)
pa_schema = pa.schema([
pa.field("product_id", pa.string(), nullable=False),
pa.field("unit_cost", pa.float64(), nullable=False),
])
# snap_early -- T1 cuesta 1.00
price.append(pa.Table.from_pylist([{"product_id": "T1", "unit_cost": 1.00}], schema=pa_schema))
snap_early = price.current_snapshot().snapshot_id
# snap_late -- T1 sube a 2.00 (un overwrite normal, igual que la leccion 3 de este modulo)
price.overwrite(pa.Table.from_pylist([{"product_id": "T1", "unit_cost": 2.00}], schema=pa_schema))
snap_late = price.current_snapshot().snapshot_id
Dos escrituras, dos snapshots — exactamente el mismo mecanismo de las lecciones 2 y 3 de este módulo. La diferencia con Kiosko es que aquí vas a simular dos ventas de T1, una anterior al cambio de precio y otra posterior — sin ninguna columna de historia, igual que kiosko.dim_product.
Paso 2 — Dos ventas, cada una con su propio costo correcto
sale_before = {"order": "toy-early-sale", "correct_cost_should_be": 1.00}
sale_after = {"order": "toy-late-sale", "correct_cost_should_be": 2.00}
cost_as_of_early = price.scan(snapshot_id=snap_early).to_arrow().to_pylist()[0]["unit_cost"]
cost_as_of_late = price.scan(snapshot_id=snap_late).to_arrow().to_pylist()[0]["unit_cost"]
for sale in (sale_before, sale_after):
ok_early = cost_as_of_early == sale["correct_cost_should_be"]
ok_late = cost_as_of_late == sale["correct_cost_should_be"]
print(f"{sale['order']}: costo real={sale['correct_cost_should_be']} "
f"AS OF snap_early={cost_as_of_early} ({'OK' if ok_early else 'INCORRECTO'}) | "
f"AS OF snap_late={cost_as_of_late} ({'OK' if ok_late else 'INCORRECTO'})")
Qué esperar (verificado corriendo el script real; snap_early/snap_late son capturados en variables, nunca hardcodeados, siguiendo la regla de la lección 4 de este módulo):
toy-early-sale: costo real=1.0 AS OF snap_early=1.0 (OK) | AS OF snap_late=2.0 (INCORRECTO)
toy-late-sale: costo real=2.0 AS OF snap_early=1.0 (INCORRECTO) | AS OF snap_late=2.0 (OK)
Ahí está el límite, con evidencia literal. Ningún snapshot_id único acierta las dos ventas a la vez. AS OF snap_early es correcto para toy-early-sale, pero incorrecto para toy-late-sale. AS OF snap_late —el estado vigente, sin necesidad siquiera de time travel— es correcto para toy-late-sale, pero incorrecto para toy-early-sale. Una consulta con un único snapshot_id fija una sola versión de la tabla completa para todas las filas que toque — no existe, dentro del mecanismo mismo de time travel, ninguna forma de decirle "para esta fila usa esta versión, y para aquella otra, usa esta otra versión distinta", dentro de la misma consulta.
shutil.rmtree(demo_warehouse)
os.remove(demo_db)
(Este catálogo y esta tabla de demostración se descartan al cerrar la lección — no forman parte de kiosko, y ningún módulo posterior de esta guía depende de ellos.)
Por qué el caso real de Kiosko no chocó con este límite
La razón no es que el time travel sea, en el caso de Kiosko, más inteligente que en este experimento — es que el caso de Kiosko cumple, por diseño de los datos, la única condición bajo la cual un único snapshot_id sí es correcto para todas las filas a la vez: las cuarenta órdenes ocurren entre el 3 y el 9 de agosto de 2026, y el único cambio de P002 entra en vigencia el 15 de agosto — todas las órdenes caen del mismo lado de ese único cambio. No hay ninguna orden de Kiosko posterior al 15 de agosto que necesitara unit_cost=0.68 mientras otra, anterior, necesitara unit_cost=0.60 dentro del mismo cálculo. snap_v1 es correcto para las cuarenta filas a la vez, sin excepción — exactamente la ausencia de la situación que el experimento de esta lección construyó a propósito con toy-early-sale y toy-late-sale.
Diagrama: cuándo un snapshot_id único basta, y cuándo no
flowchart TB
subgraph favorable["Caso favorable -- Kiosko, este modulo"]
F1["40 ordenes, TODAS antes del 15-ago"] --> F2["UN unico snapshot (snap_v1)\ncorrecto para las 40 a la vez"]
end
subgraph general["Caso general -- el experimento de esta leccion"]
G1["ventas repartidas ANTES y DESPUES\nde un cambio (o de varios cambios)"] --> G2["NINGUN snapshot_id unico\nes correcto para todas a la vez"]
G2 --> G3["se necesita saber, POR FILA,\nque version regia en SU fecha"]
end
La respuesta correcta al caso general: SCD-2 a nivel de fila
El caso general —hechos repartidos a ambos lados de uno o más cambios de dimensión, o varias filas de dimensión cambiando en fechas distintas e independientes— sigue necesitando exactamente lo que data-modeling-for-analytics-guide y dbt-analytics-engineering-guide ya enseñaron: una columna valid_from/valid_to (o su equivalente dbt_valid_from/dbt_valid_to) evaluada por fila, no por tabla completa. La diferencia estructural es esta: un snapshot_id de Iceberg describe "así se veía toda la tabla en un instante" — una sola coordenada, aplicada globalmente a cualquier consulta que la use. Una columna valid_from/valid_to describe "así estuvo esta fila específica vigente, durante esta ventana" — una coordenada independiente por cada fila, que un JOIN puede evaluar contra la fecha propia de cada hecho, sin que todas las filas del resultado tengan que compartir el mismo instante de referencia.
Esto no es una limitación de PyIceberg en particular, ni algo que una versión futura vaya a resolver de otra forma — es una diferencia de qué pregunta responde cada mecanismo. El time travel responde: "¿cómo se veía toda la tabla en el instante X?". El JOIN punto-en-el-tiempo responde: "¿qué versión de esta fila regía en la fecha propia de este hecho?". Son preguntas distintas, y la segunda es estrictamente más general que la primera —cualquier caso donde la primera basta es, también, un caso donde la segunda daría la misma respuesta; lo inverso no es cierto—.
Cuándo usar cada uno, con criterio
| Situación | Herramienta correcta |
|---|---|
| Auditar cómo se veía una tabla completa en una fecha específica (¿qué reportaba el sistema el 10 de agosto?) | Time travel — table.scan(snapshot_id=...) o AS OF |
| Recuperar un valor borrado o sobrescrito por error, de forma puntual | Time travel |
| Reproducir un reporte histórico exacto, tal como se veía cuando se publicó | Time travel |
| Calcular una métrica que depende de la versión de la dimensión vigente en la fecha de cada hecho individual, cuando la dimensión cambió más de una vez o los hechos están repartidos a ambos lados de un cambio | JOIN punto-en-el-tiempo con valid_from/valid_to a nivel de fila (a mano, como en data-modeling, o automatizado, como en dbt snapshot) |
| El caso favorable de Kiosko en este módulo: un único cambio, todos los hechos del mismo lado | Cualquiera de los dos — el time travel es más simple de escribir, sin columnas extra |
Errores comunes
Concluir que Iceberg "no necesita" SCD-2 nunca, después de ver el resultado de la lección 6. Qué pasa: alguien, impresionado con lo simple que fue recuperar 10.8 sin ninguna columna, decide que ya no vale la pena aprender ni mantener valid_from/valid_to en ningún proyecto futuro sobre Iceberg. Por qué pasa: el ejemplo de Kiosko es real, funciona, y es tentador generalizar de un caso favorable a una regla universal. Cómo detectarlo: si no puedes describir, de memoria, un escenario concreto donde el time travel falle —como el de toy-early-sale/toy-late-sale de esta lección—, todavía no interiorizaste el límite. Cómo corregirlo: vuelve al experimento de esta lección y reprodúcelo tú mismo — la evidencia de que "un snapshot_id no basta cuando hay ventas a ambos lados de un cambio" es más convincente corriéndola que leyéndola.
Pensar que el problema es "Kiosko tuvo suerte", en vez de "los datos de Kiosko cumplen una condición verificable". Qué pasa: alguien interpreta que el resultado de la lección 6 funcionó por casualidad, sin ninguna razón estructural. Por qué pasa: es fácil no notar que "todas las órdenes son anteriores al cambio" es, en realidad, una propiedad verificable de los datos —no un golpe de suerte—, y que fue diseñada así, a propósito, por data-modeling-for-analytics-guide desde el principio (la fecha de vigencia 2026-08-15 se eligió, explícitamente, posterior a las cuarenta órdenes). Cómo detectarlo: si no puedes explicar, con una fecha concreta, por qué snap_v1 es correcto para las cuarenta órdenes a la vez, revisa la sección "Por qué el caso real de Kiosko no chocó con este límite" de esta lección. Cómo corregirlo: antes de usar time travel como sustituto de un JOIN punto-en-el-tiempo en un caso real, verifica explícitamente que todos los hechos relevantes caen del mismo lado de cada cambio de dimensión que te importa — si esa verificación falla, necesitas SCD-2 a nivel de fila, no time travel.
Ejercicios
Ejercicio 1 — Reproduce el experimento de toy_price tú mismo, y confirma la tabla de aciertos/fallos. Corre el script completo de esta lección, en un directorio de trabajo separado del resto de este módulo. Confirma que obtienes exactamente la tabla de dos filas mostrada en "Qué esperar", con ningún snapshot_id acertando ambas ventas.
Ver solución
Tu salida debería coincidir con la de esta lección: toy-early-sale correcta solo con snap_early, toy-late-sale correcta solo con snap_late, sin ningún snapshot_id que acierte las dos filas a la vez. Si por alguna razón un solo snapshot_id "acertara" ambas —algo que no debería pasar con este experimento tal como está construido—, revisa que de verdad hayas usado dos costos distintos (1.00 y 2.00) para las dos escrituras.
Ejercicio 2 — Extiende el experimento con una tercera venta, en un momento intermedio hipotético. Sin correrlo todavía, predice: si agregaras una tercera "venta" con correct_cost_should_be igual a un valor que nunca existió en toy_price —por ejemplo, 1.50, un precio que T1 nunca tuvo—, ¿algún snapshot_id de los dos existentes podría acertarla?
Ver solución
No — ningún snapshot_id puede devolver un valor que nunca fue el estado vigente de la tabla en ningún momento. El time travel solo puede reconstruir estados que de verdad existieron como snapshots reales; no puede interpolar, promediar, ni inventar un estado intermedio que nunca se escribió. Este es un límite adicional, distinto del que muestra el ejemplo principal de esta lección: el time travel reconstruye fotos que existen, no cualquier estado hipotético que a alguien se le ocurra preguntar.
Ejercicio 3 — Explica, en tus propias palabras y sin mirar la tabla de esta lección, cuándo el time travel SÍ es la herramienta correcta. En 2-3 frases, describe un escenario —no necesariamente de Kiosko— donde el time travel de este módulo sea exactamente la herramienta correcta, y explica por qué un JOIN punto-en-el-tiempo sería, en ese caso, trabajo innecesario.
Ver solución
No hay una única respuesta correcta, pero un buen ejemplo es: "quiero saber exactamente qué reportaba el dashboard de ventas el 1 de agosto, antes de que corrigiéramos un error de carga el 2 de agosto" — aquí no hay ningún hecho "repartido a ambos lados" de nada; la pregunta es, literalmente, "cómo se veía la tabla completa en un instante fijo", que es exactamente la pregunta que el time travel responde de forma nativa. Escribir un JOIN punto-en-el-tiempo con valid_from/valid_to para responder esa pregunta sería trabajo de más: tendrías que declarar, poblar y mantener columnas de historia solo para reconstruir algo que Iceberg ya archiva automáticamente en cada commit.
Resumen y siguiente paso
En esta lección construiste, con código real y aislado del modelo de Kiosko, la prueba de que un snapshot_id único no puede servir correctamente a dos hechos que necesitan versiones distintas de la misma dimensión al mismo tiempo. Confirmaste, con la fecha exacta del cambio de P002 (2026-08-15, posterior a las cuarenta órdenes), por qué el caso de Kiosko cayó del lado favorable de ese límite — no por casualidad, sino por una condición verificable de los datos. Y viste, con una tabla de decisión, cuándo el time travel es la herramienta correcta y cuándo el caso general sigue necesitando SCD-2 a nivel de fila, a mano o automatizado.
Antes de avanzar deberías poder: explicar, con un ejemplo propio, un escenario donde el time travel dé un resultado incorrecto; explicar la condición exacta que hizo que el caso de Kiosko funcionara con time travel; y decidir, frente a un caso nuevo, cuál de las dos técnicas corresponde.
Con las siete lecciones anteriores completas, la lección 8 junta todo en un proyecto único: kiosko.dim_product creada, historizada por time travel, verificada de punta a punta con assert automáticos.
Recursos
- Apache Iceberg — documentación oficial, "Table Spec", sección de "Snapshots", la definición formal de que un snapshot describe el estado completo de una tabla, no de una fila individual. iceberg.apache.org/spec. En inglés.
- PyIceberg — referencia de API,
table.scan(snapshot_id=...), el mecanismo cuyo límite estructural esta lección demuestra. py.iceberg.apache.org/api. En inglés. - DISEÑO de
data-modeling-for-analytics-guide— fuente delJOINpunto-en-el-tiempo a nivel de fila (valid_from/valid_to), la técnica que sigue siendo necesaria para el caso general que esta lección describe.src/guides/data-modeling-for-analytics-guide/DISENO.md. En español. - DISEÑO de
dbt-analytics-engineering-guide— fuente dedbt snapshot, la versión automatizada de la misma técnica a nivel de fila.src/guides/dbt-analytics-engineering-guide/DISENO.md. En español. - DISEÑO de esta guía — la frontera explícita del módulo 3: "el caso general de una dimensión que cambia muchas veces con hechos repartidos entre varias versiones sigue necesitando SCD-2 a nivel de fila".
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.