Módulo 4: Schema Evolution Without Rewriting

Poblando country en dim_store

Descripción

Las lecciones 3 y 4 construyeron el mecanismo completo de evolución de esquema —agregar, renombrar, borrar— sin poblar country con ningún valor real todavía. Esta lección hace el trabajo que faltaba: llena la columna country de las tres tiendas de Kiosko, de forma determinística, a partir de cityBogotá→Colombia, Lima→Peru, Santiago→Chile—. La herramienta no es ninguna operación nueva de esquema: es table.overwrite(), la misma que ya usaste en el módulo 3 para el cambio de P002, aplicada ahora sobre una tabla con una columna adicional.

Conexión con el módulo. Esta es, con precisión, la lección "de pago" de este módulo: hasta aquí, country existía en el esquema, pero no tenía ningún valor real. Esta lección cierra ese hueco. Y hace algo más, casi sin querer: demuestra que poblar una columna nueva es una operación de datos, con su propio snapshot, mientras que agregar esa misma columna al esquema fue una operación de metadata, sin ningún snapshot — la distinción exacta que la lección 3 introdujo, ahora aplicada con un resultado de negocio real.

Una analogía: completar el casillero en blanco, no rediseñar el formulario

Retomando el censo de la lección 1: el formulario ya tiene la cuarta pregunta —country— desde la lección 3. Lo que faltaba era que alguien, con la información que ya tenía a mano —la ciudad de cada casa—, complete el casillero en blanco con la respuesta correcta. Nadie vuelve a diseñar el formulario. Nadie vuelve a tocar la puerta con una pregunta nueva. Es, sencillamente, completar lo que ya se sabía, con una regla clara y sin ambigüedad: cada ciudad de Kiosko pertenece a un único país, así que la respuesta correcta para cada casa está determinada por completo por un dato que la ficha ya tenía.

Ejemplo trabajado: country, poblado desde city

Paso 1 — El mapeo determinístico: una ciudad, un país, siempre

# populate_country.py
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_store")

# el mapeo es fijo, de negocio -- las tres ciudades de Kiosko, cada una con un unico pais
CITY_TO_COUNTRY = {"Bogota": "Colombia", "Lima": "Peru", "Santiago": "Chile"}

DIM_STORE_WITH_COUNTRY = [
    {"store_id": "S01", "store_name": "Kiosko Centro", "city": "Bogota", "country": CITY_TO_COUNTRY["Bogota"]},
    {"store_id": "S02", "store_name": "Kiosko Norte", "city": "Lima", "country": CITY_TO_COUNTRY["Lima"]},
    {"store_id": "S03", "store_name": "Kiosko Sur", "city": "Santiago", "country": CITY_TO_COUNTRY["Santiago"]},
]

dim_store_pa_schema_v2 = pa.schema([
    pa.field("store_id", pa.string(), nullable=False),
    pa.field("store_name", pa.string(), nullable=False),
    pa.field("city", pa.string(), nullable=False),
    pa.field("country", pa.string(), nullable=True),
])

pa_table = pa.Table.from_pylist(DIM_STORE_WITH_COUNTRY, schema=dim_store_pa_schema_v2)
table.overwrite(pa_table)

print("table.scan().to_arrow() tras poblar country:")
for row in table.scan().to_arrow().to_pylist():
    print(f"  {row['store_id']}  {row['store_name']:<15} {row['city']:<10} country={row['country']}")

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

table.scan().to_arrow() tras poblar country:
  S01  Kiosko Centro   Bogota     country=Colombia
  S02  Kiosko Norte    Lima       country=Peru
  S03  Kiosko Sur      Santiago   country=Chile

Las tres tiendas de Kiosko, cada una con su país correcto — sin ninguna ambigüedad, porque el mapeo CITY_TO_COUNTRY cubre exactamente las tres ciudades que existen en dim_store, ni una más ni una menos. Nada en este paso involucró random, ni un servicio externo de geolocalización, ni ningún valor que dependa de cuándo corras el script: Bogota es, siempre, Colombia.

Paso 2 — Por dentro, otra vez append, delete, append

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

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

table.history() tiene 3 entradas
  operation=append   added-records=3 deleted-records=- total-records=3
  operation=delete   added-records=- deleted-records=3 total-records=0
  operation=append   added-records=3 deleted-records=- total-records=3

Reconoces este patrón: es exactamente el mismo que el módulo 3 encontró al sobrescribir kiosko.dim_product con los valores V2 de P002 — un overwrite() sin filtro reemplaza el 100% de las filas, y Iceberg lo resuelve internamente como un delete completo seguido de un append completo, dos snapshots dentro de una sola llamada. La lección 2 de este módulo ya demostró, con 490 lecturas concurrentes, que ningún lector externo llega a ver ese estado intermedio de total-records=0 como el estado vigente de la tabla — aquí, sobre kiosko.dim_store, aplica exactamente la misma garantía.

Diagrama: metadata primero, datos después

flowchart TB
    A["Leccion 3:\nadd_column('country')\nMETADATA -- 0 snapshots,\n0 archivos tocados"] --> B["dim_store: 4 columnas,\ncountry=None en las 3 filas"]
    B --> C["Leccion 5 (esta):\ntable.overwrite(...)\nDATOS -- append + delete + append"]
    C --> D["dim_store: 4 columnas,\ncountry poblado\nS01=Colombia, S02=Peru, S03=Chile"]
    D -.->|"archivo Parquet ORIGINAL\n(leccion 3, sin country)"| E["sigue existiendo en disco,\nreferenciado por snap_before_evolution"]
    D -.->|"archivo Parquet NUEVO\n(esta leccion, con country)"| F["es el que table.scan()\nlee por defecto ahora"]

Fíjate en algo importante para la lección 6: el archivo Parquet original —el que la lección 3 creó, sin countryno desapareció. table.overwrite() dejó de referenciarlo desde el snapshot vigente, pero el archivo sigue físicamente en disco, porque snap_before_evolution —capturado en la lección 3, antes de cualquier evolución— todavía lo necesita para responder correctamente a un table.scan(snapshot_id=snap_before_evolution).

Profundización: por qué esto no es "evolución de esquema", es una escritura normal

Vale la pena ser preciso sobre una distinción que esta lección hace, a propósito, sin dramatismo: poblar country no usa table.update_schema() para nada. La columna ya existe en el esquema desde la lección 3; lo que esta lección hace es una escritura de datos completamente normal, del mismo tipo que cualquier table.overwrite() que ya conoces del módulo 3. La razón de separar esto en dos lecciones distintas —agregar la columna (lección 3) contra poblarla (esta lección)— no es arbitraria: son, con precisión, dos tipos de cambio diferentes en el modelo de Iceberg, con costos y garantías distintas. Agregar una columna es instantáneo, sin importar cuántas filas tenga la tabla, porque no toca ningún dato. Poblarla con valores reales sí tiene el costo de una escritura normal —proporcional a cuántas filas hay que reescribir—, exactamente como cualquier overwrite(). Confundir estas dos operaciones, tratándolas como si fueran una sola, es perder de vista una de las ideas centrales de este módulo.

Errores comunes

Intentar poblar country con table.update_schema().update_column(). Qué pasa: alguien, buscando cómo "llenar" la columna nueva, encuentra update_column() en la referencia de API de PyIceberg y asume que sirve para asignar valores. Por qué pasa: el nombre update_column suena, por asociación, a "actualizar el contenido de una columna". Cómo detectarlo: si tu código intenta pasar un valor de fila a update_column(), revisa su firma real: update_column(path, field_type=None, required=None, doc=None) — no recibe ningún valor de fila, solo cambios de tipo, de obligatoriedad o de documentación de la columna completa. Cómo corregirlo: para poblar valores reales de fila, la herramienta correcta es una escritura de datos —table.overwrite(), como esta lección, o table.upsert(), que el módulo 6 de esta guía enseña como alternativa dirigida por clave—, nunca una operación de update_schema().

Olvidar incluir store_id, store_name y city en el overwrite(), y pensar que solo hace falta escribir country. Qué pasa: alguien, enfocado en la columna nueva, intenta construir un pa.Table que solo tenga las columnas store_id y country, esperando que Iceberg "combine" ese resultado con las filas existentes. Por qué pasa: en algunos sistemas, una operación de UPDATE afecta solo las columnas que mencionas explícitamente, dejando el resto intacto — y es natural esperar el mismo comportamiento aquí. Cómo detectarlo: si tu overwrite() falla con un error de esquema, o produce filas con columnas faltantes, revisa que tu pa.Table tenga las cuatro columnas completas. Cómo corregirlo: table.overwrite() reemplaza toda la tabla (o la porción que cubra el filtro que le pases) con el contenido exacto del pa.Table que le des — exactamente el mismo comportamiento que el módulo 3 ya advirtió sobre DIM_PRODUCT_V2, que tuvo que incluir las tres filas sin cambios de P001, P003 y P004 para que sobrevivieran al overwrite() del cambio de P002.

Ejercicios

Ejercicio 1 — Reproduce el overwrite() tú mismo, y confirma los tres países. Con kiosko.dim_store en el estado que dejó la lección 4 (cuatro columnas, country=None), corre el script de esta lección. Confirma que table.scan().to_arrow() muestra Colombia, Peru y Chile, en la fila correcta de cada tienda.

Ver solución

Si partiste del estado exacto de la lección 4, tu salida debería coincidir con la de esta lección: S01 con country=Colombia, S02 con country=Peru, S03 con country=Chile, y table.history() con tres entradas nuevas —append, delete, append— sumadas al 1 que ya tenía la tabla desde la lección 3.

Ejercicio 2 — Extiende CITY_TO_COUNTRY para una cuarta tienda hipotética, y explica qué pasaría si la ciudad no estuviera en el diccionario. Sin correrlo, predice: si Kiosko abriera una S04 en Medellin sin agregar "Medellin": "Colombia" al diccionario CITY_TO_COUNTRY, ¿qué pasaría al construir DIM_STORE_WITH_COUNTRY con esa fila incluida?

Ver solución

CITY_TO_COUNTRY["Medellin"] lanzaría un KeyError en Python, porque el diccionario no tiene esa clave — el script fallaría de inmediato, antes de siquiera intentar escribir nada en Iceberg. Este es, en realidad, el comportamiento deseable: preferir un error explícito y visible (KeyError) sobre completar country=None silenciosamente para una tienda nueva, o peor, adivinar un valor incorrecto. Un mapeo determinístico que falla ruidosamente ante un caso no cubierto es más seguro que uno que produce datos incompletos sin avisar.

Ejercicio 3 — Explica, en tus propias palabras, por qué esta lección no usa random para asignar países. En 1-2 frases, conecta esta decisión con la regla dura del resto de esta guía sobre determinismo.

Ver solución

Esta guía completa prohíbe random, datetime.now() y time.time() en cualquier código que alimente un bloque "Qué esperar", porque el objetivo es que cualquier lector pueda reproducir exactamente los mismos resultados de negocio en su propia máquina —los tres países correctos, no un valor que cambie entre corridas—. CITY_TO_COUNTRY es un mapeo fijo, igual que DIM_STORE o DIM_PRODUCT en las lecciones anteriores de esta guía: los datos de negocio de Kiosko siempre son los mismos, sin importar cuándo o cuántas veces se corra el script.

Resumen y siguiente paso

En esta lección poblaste country con los tres países reales de Kiosko —Colombia, Peru, Chile—, derivados de forma determinística de city con un simple diccionario Python. Confirmaste que esta operación, a diferencia de agregar la columna en la lección 3, sí es una escritura de datos —con su propio patrón append/delete/append, ya conocido del módulo 3—. Y viste, en el diagrama, que el archivo Parquet original de la lección 3 sigue existiendo en disco, sin que nadie lo haya tocado.

Antes de avanzar deberías poder: distinguir con claridad "agregar una columna al esquema" de "poblarla con valores reales"; y explicar por qué la segunda operación sí crea snapshots de datos, mientras que la primera no crea ninguno.

kiosko.dim_store tiene ahora sus cuatro columnas completas, con los tres países correctos. La lección 6 confirma algo que todavía no probaste: que table.scan(snapshot_id=snap_before_evolution) —el snapshot capturado en la lección 3, antes de que country existiera— sigue leyéndose con su esquema original de tres columnas, sin ningún rastro de country.

Recursos

  • PyIceberg — referencia de API, table.overwrite(), reutilizado sin cambios desde el módulo 3 sobre una tabla con un esquema evolucionado. py.iceberg.apache.org/api. En inglés.
  • PyIceberg — referencia de API, UpdateSchema.update_column(path, field_type=None, required=None, doc=None), la operación de esquema que esta lección aclara que no sirve para poblar valores de fila. py.iceberg.apache.org/api. En inglés.
  • DISEÑO de data-engineering-foundations-guide — fuente del esquema original de stores y de las tres ciudades exactas de Kiosko (Bogota, Lima, Santiago) que determinan el mapeo CITY_TO_COUNTRY. src/guides/data-engineering-foundations-guide/DISENO.md. En español.
  • DISEÑO de esta guía — la sección "Evolución de esquema" (M4), fuente exacta del mapeo country que esta lección puebla. src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.