Módulo 4: Schema Evolution Without Rewriting

Leyendo snapshots antiguos después de un cambio de esquema

Descripción

kiosko.dim_store tiene, desde la lección 5, cuatro columnas completas: store_id, store_name, city, country. Pero snap_before_evolution —capturado en la lección 3, antes de que country existiera siquiera— sigue archivado, disponible, exactamente como cualquier snapshot anterior en esta guía. Esta lección responde una pregunta que no es obvia a primera vista: cuando le pides a Iceberg table.scan(snapshot_id=snap_before_evolution), ¿qué esquema usa para leer esos datos — el de entonces, con tres columnas, o el vigente, con cuatro?

Conexión con el módulo. El módulo 3 de esta guía enseñó time travel para datos: recuperar el estado de P002 antes de un overwrite(). Esta lección aplica la misma idea de fondo —"un snapshot es una foto completa, archivada, nunca retocada"— a un caso distinto: un snapshot anterior a un cambio de esquema. La pregunta que responde no es "¿qué valores tenía esta fila?", sino "¿qué forma tenía esta tabla?" — y la respuesta, con evidencia real, es que cada snapshot recuerda su propio esquema, no el que la tabla tiene ahora.

Una analogía: leer una carta vieja con el vocabulario de la época en que se escribió

Piensa en una carta guardada en un archivo, escrita antes de que existiera cierta palabra del idioma —antes de que alguien inventara, digamos, el término "internet"—. Cuando alguien saca esa carta del archivo y la lee hoy, la lee tal como fue escrita: con el vocabulario de su época, sin ninguna palabra que todavía no existía cuando se escribió. Nadie reescribe la carta agregándole el vocabulario nuevo con el que hablamos hoy. La carta es un documento fijo, de un momento fijo, con las palabras que existían en ese momento — y así se lee, sin importar cuánto haya cambiado el idioma desde entonces.

Eso es, con precisión, lo que un snapshot de Iceberg hace con el esquema. snap_before_evolution es una carta escrita antes de que country existiera en el vocabulario de dim_store. Leerla hoy —con table.scan(snapshot_id=snap_before_evolution)— no le agrega country de forma retroactiva. La devuelve, tal como fue escrita, con exactamente las tres columnas que existían en ese momento.

Ejemplo trabajado: la misma tabla, dos esquemas distintos, según qué snapshot pidas

Paso 1 — Recupera snap_before_evolution, y compara los dos esquemas

# read_old_schema.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.dim_store")

# recuperado por posicion en el historial -- el PRIMER append, antes de cualquier
# operacion de esquema o de datos posterior (asumiendo que corriste las lecciones
# 3 a 5 de este modulo en el mismo directorio, en orden)
history = table.history()
snap_before_evolution = history[0].snapshot_id

old_scan = table.scan(snapshot_id=snap_before_evolution).to_arrow()
current_scan = table.scan().to_arrow()

print("table.scan(snapshot_id=snap_before_evolution).schema:")
print(old_scan.schema)
print()
print("Filas leidas con el esquema anterior:")
for row in old_scan.to_pylist():
    print(" ", row)

print()
print("Comparacion de columnas:")
print("  vigente (table.scan()):                     ", current_scan.schema.names)
print("  historico (snapshot_id=snap_before_evolution):", old_scan.schema.names)
print()
print("'country' en la lectura historica:", "country" in old_scan.schema.names)

Qué esperar (verificado corriendo el script real; snap_before_evolution es el snapshot_id de tu propia corrida, distinto cada vez — la estructura y el contenido de la comparación son deterministas):

table.scan(snapshot_id=snap_before_evolution).schema:
store_id: string not null
store_name: string not null
city: string not null

Filas leidas con el esquema anterior:
  {'store_id': 'S01', 'store_name': 'Kiosko Centro', 'city': 'Bogota'}
  {'store_id': 'S02', 'store_name': 'Kiosko Norte', 'city': 'Lima'}
  {'store_id': 'S03', 'store_name': 'Kiosko Sur', 'city': 'Santiago'}

Comparacion de columnas:
  vigente (table.scan()):                      ['store_id', 'store_name', 'city', 'country']
  historico (snapshot_id=snap_before_evolution): ['store_id', 'store_name', 'city']

'country' en la lectura historica: False

Ahí está la respuesta, con evidencia literal: la misma tabla, la misma variable table, dos llamadas de lectura distintas, dos esquemas distintos. table.scan() sin argumentos —el vigente— trae las cuatro columnas, con country poblado. table.scan(snapshot_id=snap_before_evolution) trae exactamente las tres columnas que existían en el momento de ese commit — ni una country de más, ni siquiera como None. Iceberg no "retroaplica" el esquema nuevo a un snapshot viejo; cada snapshot conserva la referencia al esquema que tenía vigente cuando se creó.

Paso 2 — Por qué esto funciona: cada snapshot referencia su propio schema-id

print("schema_id vigente de la tabla:", table.schema().schema_id)
print("Cuantos esquemas distintos archiva el metadata:", len(table.metadata.schemas))

for snap in table.metadata.snapshots:
    print(f"  snapshot {snap.snapshot_id == snap_before_evolution and 'snap_before_evolution' or '...'}"
          f" -> schema_id={snap.schema_id}")

Qué esperar (verificado corriendo el script real, sobre la tabla que dejaron las lecciones 3 a 5; el número exacto de schema_id vigente puede variar según el orden exacto de operaciones de tu propia corrida, pero el mecanismo que demuestra es el mismo):

schema_id vigente de la tabla: 1
Cuantos esquemas distintos archiva el metadata: 4

  snapshot snap_before_evolution -> schema_id=0
  snapshot ... -> schema_id=1

Cada entrada de table.metadata.snapshots —cada foto archivada— trae consigo un schema_id, no solo una lista de archivos de datos. snap_before_evolution apunta al schema_id=0, el esquema de tres columnas con el que se creó. El snapshot vigente apunta a un schema_id posterior, el de cuatro columnas. table.metadata.schemas guarda todas las versiones de esquema que la tabla tuvo alguna vez, no solo la última — y cada snapshot elige, de esa lista, cuál le corresponde. Leer un snapshot antiguo consiste, con precisión, en usar el schema_id que ese snapshot apunta, nunca el vigente.

Diagrama: cada snapshot, con su propio esquema

flowchart TB
    subgraph meta["metadata.json vigente"]
        SCH0["schema_id=0\nstore_id, store_name, city"]
        SCH1["schema_id=1\nstore_id, store_name, city, country"]
    end
    subgraph snaps["snapshots archivados"]
        SB["snap_before_evolution\n(leccion 3)"] -->|"apunta a"| SCH0
        SV["snapshot vigente\n(leccion 5)"] -->|"apunta a"| SCH1
    end
    SB -.->|"table.scan(snapshot_id=snap_before_evolution)"| R0["3 columnas,\nsin country"]
    SV -.->|"table.scan()"| R1["4 columnas,\ncon country"]

Profundización: qué pasaría si el snapshot viejo tuviera una columna que ya no existe

Esta lección mostró el caso de una columna agregada después de un snapshot —country no existe al leer snap_before_evolution—. El caso simétrico también es cierto, aunque esta guía no lo ejecuta sobre dim_store para no perder el hilo de negocio de Kiosko: si hubieras leído snap_before_evolution después de que la lección 4 hubiera borrado, por ejemplo, la columna city —hipotéticamente, no es lo que pasó—, ese snapshot viejo seguiría mostrando city, con sus valores originales, porque el field_id de city sigue existiendo en el archivo Parquet de ese snapshot, y el schema_id=0 al que apunta todavía la declara. Una columna borrada del esquema vigente no desaparece de los snapshots que la tenían — sigue siendo parte de su propia foto, leíble con precisión, exactamente como cualquier otro dato archivado. Esto es consistente con la garantía que la lección 3 de este módulo ya citó de la documentación oficial: "Dropping a column or field does not change the values in any other column" — y tampoco cambia los valores de los snapshots que existían antes del DROP.

Errores comunes

Asumir que table.scan(snapshot_id=...) siempre devuelve el esquema vigente, con columnas nuevas mostradas como None. Qué pasa: alguien, familiarizado con cómo se comportó country dentro del esquema vigente después de la lección 3 (None para las tres filas), espera ver ese mismo None al leer snap_before_evolution, en vez de que la columna directamente no aparezca. Por qué pasa: es una generalización razonable, pero incorrecta, de "una columna sin valor se muestra como None" aplicada al caso equivocado — esa regla aplica a filas existentes leídas con el esquema vigente después de un add_column, no a snapshots que son anteriores a que la columna existiera en absoluto. Cómo detectarlo: si tu código espera una clave country en cada fila de old_scan.to_pylist(), y en cambio obtienes un KeyError, revisa que estés leyendo el snapshot correcto. Cómo corregirlo: verifica siempre old_scan.schema.names antes de acceder a una columna por nombre en una lectura histórica — si esa columna no existe en la lista, no va a estar en ninguna fila, ni siquiera como None.

Intentar identificar snap_before_evolution por posición fija en table.history(), sin verificar. Qué pasa: alguien asume que history()[0] siempre corresponde a snap_before_evolution, sin importar cuántas operaciones adicionales haya corrido antes. Por qué pasa: en el flujo exacto de este módulo, tal como está descrito, resulta que sí es la posición cero — y es fácil generalizar ese resultado particular. Cómo detectarlo: si corriste algún experimento adicional antes de las lecciones 3 a 5 —por ejemplo, si repetiste alguna lección más de una vez sobre el mismo catálogo—, la posición cero puede no corresponder ya a lo que esperas. Cómo corregirlo: la lección 4 del módulo 3 ya enseñó la técnica robusta para este caso exacto — recorrer table.history() y verificar el contenido de cada candidato (aquí, el esquema de cada snapshot con table.metadata.snapshots[i].schema_id) en vez de confiar en una posición fija. La forma más segura de este módulo en particular es capturar snap_before_evolution en una variable en el mismo momento en que se crea, como hizo la lección 3, y no depender de recuperarlo después.

Ejercicios

Ejercicio 1 — Reproduce la comparación de esquemas tú mismo. Con kiosko.dim_store en el estado que dejaron las lecciones 3 a 5, corre el script del paso 1 de esta lección. Confirma que old_scan.schema.names tiene exactamente tres elementos, y que current_scan.schema.names tiene exactamente cuatro.

Ver solución

Si seguiste las lecciones 3 a 5 en orden, sobre el mismo directorio, tu salida debería coincidir exactamente con la de esta lección: tres columnas en la lectura histórica (store_id, store_name, city), cuatro en la vigente (con country agregada al final). El snap_before_evolution recuperado va a ser un entero distinto al de esta lección — eso es exactamente lo esperado.

Ejercicio 2 — Explica por qué table.metadata.schemas puede tener más de dos entradas, aunque solo veas dos esquemas "en uso". Basándote en las lecciones 3 y 4 de este módulo, explica por qué el metadata de dim_store archiva cuatro esquemas distintos (schema_id 0 a 3), aunque solo dos de ellos —el original y el vigente— sean los que de verdad importan para el negocio.

Ver solución

Cada operación de update_schema() que produce una estructura de columnas distinta a cualquiera ya archivada crea un schema_id nuevo — y la lección 4 de este módulo hizo varias: agregar country (un schema_id nuevo), renombrar store_name a outlet_name (otro), renombrar de vuelta (que, si la estructura resultante coincide exactamente con una ya archivada, puede reutilizar ese schema_id en vez de crear uno nuevo), agregar temp_notes (otro), y borrarla. Iceberg archiva cada estructura distinta que la tabla tuvo alguna vez, no solo las que "importan" para el negocio de hoy — es la misma filosofía de nunca perder historia que ya viste con los snapshots de datos, aplicada aquí a los esquemas.

Ejercicio 3 — Predicción: ¿qué pasaría si leyeras un snapshot capturado DESPUÉS de poblar country, pero ANTES de que existiera temp_notes? Sin correrlo, predice: si capturaras un snapshot_id justo después del overwrite() de la lección 5, y temp_notes nunca hubiera existido en esa versión de la tabla (imaginando un orden distinto de lecciones), ¿qué columnas esperarías ver al leer ese snapshot?

Ver solución

Exactamente las cuatro columnas de negocio: store_id, store_name, city, country — sin temp_notes, porque esa columna nunca llegó a existir en el esquema vigente en el momento en que ese snapshot hipotético se habría capturado. Este ejercicio refuerza la idea central de la lección: un snapshot recuerda el esquema exacto que la tabla tenía en el instante de su propio commit, ni antes ni después — nunca "todas las columnas que la tabla llegó a tener alguna vez a lo largo de su historia completa".

Resumen y siguiente paso

En esta lección confirmaste, con evidencia real, que table.scan(snapshot_id=snap_before_evolution) sigue leyendo kiosko.dim_store con su esquema original de tres columnas —sin country—, aunque el esquema vigente de la tabla ya tenga cuatro. Viste el mecanismo exacto que lo hace posible: cada snapshot archiva una referencia a su propio schema_id, y table.metadata.schemas guarda todas las versiones de esquema que la tabla tuvo alguna vez, no solo la última.

Antes de avanzar deberías poder: leer un snapshot anterior a una evolución de esquema y confirmar que trae el esquema correcto para ese momento; y explicar, con tus propias palabras, por qué cada snapshot referencia su propio schema_id en vez de usar siempre el vigente.

Con la evolución de esquema completamente verificada —agregar, poblar, renombrar, borrar, y leer el pasado sin que nada de eso se mezcle—, la lección 7 da un paso atrás para responder la pregunta que abrió este módulo con precisión total: ¿qué garantiza exactamente la palabra "ACID" en el contexto de una sola tabla Iceberg?

Recursos

  • PyIceberg — referencia de API, table.scan(snapshot_id=...), table.metadata.schemas, table.metadata.snapshots, los tres puntos de entrada que esta lección usa para comparar esquemas entre snapshots. py.iceberg.apache.org/api. En inglés.
  • Apache Iceberg — documentación oficial, "Evolution", sección "Correctness", la garantía formal de que borrar o agregar una columna no cambia los valores de ninguna otra —incluidos los snapshots ya archivados—. iceberg.apache.org/docs/latest/evolution. En inglés.
  • DISEÑO de esta guía — la sección "Evolución de esquema" (M4), fuente exacta de la verificación table.scan(snapshot_id=snap_before_evolution).to_arrow() que esta lección ejecuta. src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.