Módulo 6: Merge Into And Native Upserts
Tres formas en que Kiosko ya resolvió esto
Descripción
Antes de instalar nada nuevo, esta lección se detiene en algo que vale la pena tener fresco, con código real y no con un resumen vago: Kiosko ya resolvió el cambio de P002 tres veces, con tres herramientas que no se parecen entre sí. Esta lección no ejecuta ningún script — cita, literal, el código y la salida exacta que cada guía anterior ya produjo, y pone las tres una al lado de la otra para que la lección 3 en adelante tenga, con precisión, contra qué comparar la cuarta y la quinta técnica.
Conexión con el módulo. La lección 1 prometió esta comparación. Esta lección la entrega, con evidencia citada —no inventada— de data-modeling-for-analytics-guide, dbt-analytics-engineering-guide, y el propio módulo 3 de esta guía. Ninguna de las tres técnicas se corre de nuevo aquí: los números y la salida que ves abajo son exactamente los que esas guías ya verificaron ejecutando el código real.
Técnica 1 — MERGE INTO a mano, sobre DuckDB (data-modeling-for-analytics-guide, módulo 4)
data-modeling-for-analytics-guide construyó dim_product_scd, una tabla con columnas de historia explícitas —valid_from, valid_to, is_current—, y usó el MERGE INTO nativo de DuckDB para cerrar la versión vieja de P002 y abrir la nueva. El corazón del statement, citado literal de esa guía:
def merge_scd(change_date):
result = con.sql(f"""
MERGE INTO dim_product_scd AS target
USING staging_product AS source
ON target.product_id = source.product_id AND target.is_current = true
WHEN MATCHED AND (
target.unit_cost <> source.unit_cost OR
target.category <> source.category
) THEN UPDATE SET
valid_to = DATE '{change_date}' - INTERVAL 1 DAY,
is_current = false
RETURNING merge_action, product_id, category, unit_cost, valid_to, is_current
""")
...
# Segundo statement, obligatorio: abre la fila nueva para los product_id
# que el MERGE de arriba acaba de cerrar en ESTA corrida.
Fíjate en la condición del ON: no basta con target.product_id = source.product_id — hace falta AND target.is_current = true, porque una vez que P002 tiene más de una versión, un MERGE sin ese filtro encontraría dos filas candidatas para la misma fuente. Y fíjate en el comentario del segundo statement: MERGE INTO, en DuckDB, cierra filas —nunca abre una nueva en el mismo statement—, así que abrir la versión vigente de P002 con category='health-snacks' necesitó un INSERT de seguimiento, ejecutado inmediatamente después, filtrado con precisión por los product_id que el MERGE de arriba acababa de cerrar.
La salida real de esa guía, al correr el MERGE con el cambio real de P002 (citada literal):
--- MERGE INTO dim_product_scd (change_date = 2026-08-15) ---
┌──────────────┬────────────┬──────────┬───────────┬────────────┬────────────┐
│ merge_action │ product_id │ category │ unit_cost │ valid_to │ is_current │
├──────────────┼────────────┼──────────┼───────────┼────────────┼────────────┤
│ UPDATE │ P002 │ snacks │ 0.6 │ 2026-08-14 │ false │
└──────────────┴────────────┴──────────┴───────────┴────────────┴────────────┘
Una fila, merge_action='UPDATE' — la fila que se cerró, no la que se abrió (RETURNING refleja el estado del target después del UPDATE, que solo tocó valid_to/is_current). El resultado final, después del INSERT de seguimiento: dim_product_scd con cinco filas, P002 con dos versiones (product_key=2 cerrada, product_key=5 vigente) — el mismo par de números (margin=10.8 correcto, margin=9.36 roto) que vas a ver en las otras dos técnicas.
Técnica 2 — dbt snapshot, automatizado (dbt-analytics-engineering-guide, módulo 5)
dbt-analytics-engineering-guide resolvió exactamente el mismo problema —cerrar la versión vieja, abrir la nueva— sin que nadie escribiera un UPDATE ni un INSERT. Un archivo de configuración declarativo (dim_product_snapshot.sql, con strategy: timestamp, comparando product_updated_at), y un solo comando:
dbt snapshot
La salida real, citada literal, al correr ese comando por segunda vez —con products_v2.csv ya apuntado como fuente, el archivo que trae el cambio real de P002—:
Running with dbt=1.12.2
Registered adapter: duckdb=1.11.0
Found 7 models, 1 snapshot, 15 data tests, 4 sources, 501 macros
1 of 1 START snapshot main.dim_product_snapshot ................................ [RUN]
[WARNING]: Data type of snapshot table timestamp columns (TIMESTAMP) doesn't match derived column 'updated_at' (DATE). Please update snapshot config 'updated_at'.
1 of 1 OK snapshotted main.dim_product_snapshot ................................ [OK in 0.12s]
Done. PASS=1 WARN=0 ERROR=0 SKIP=0 NO-OP=0 REUSED=0 TOTAL=1
Fíjate en algo que la propia guía de dbt señaló como diferencia importante: esta salida no dice cuántas filas cambiaron. A diferencia del RETURNING de DuckDB, dbt snapshot reporta éxito u error a nivel del recurso completo — para confirmar el efecto real, esa guía tuvo que consultar la tabla directamente:
┌────────────┬───────────────┬───────────┬─────────────────────┬─────────────────┬───────────────┐
│ product_id │ category │ unit_cost │ product_updated_at │ dbt_valid_from │ dbt_valid_to │
├────────────┼───────────────┼───────────┼─────────────────────┼─────────────────┼───────────────┤
│ P001 │ beverages │ 0.4 │ 2026-08-01 │ 2026-08-01 │ NULL │
│ P002 │ snacks │ 0.6 │ 2026-08-01 │ 2026-08-01 │ 2026-08-15 │
│ P002 │ health-snacks │ 0.68 │ 2026-08-15 │ 2026-08-15 │ NULL │
│ P003 │ beverages │ 0.35 │ 2026-08-01 │ 2026-08-01 │ NULL │
│ P004 │ electronics │ 2.1 │ 2026-08-01 │ 2026-08-01 │ NULL │
└────────────┴───────────────┴───────────┴─────────────────────┴─────────────────┴───────────────┘
Cinco filas, P002 con dos versiones — el mismo resultado, número por número, que la Técnica 1 produjo con MERGE INTO escrito a mano. La diferencia real no está en el resultado: está en que nadie escribió un solo UPDATE ni INSERT — dbt snapshot, con la configuración declarada una sola vez, decide por sí mismo cuándo cerrar y cuándo abrir, comparando product_updated_at contra lo ya archivado.
Técnica 3 — table.overwrite() + time travel, cero columnas (esta guía, módulo 3)
Esta misma guía ya resolvió el mismo cambio, con una tercera técnica que ninguna de las dos anteriores usa: sin ninguna columna de historia en absoluto. kiosko.dim_product, creada en el módulo 3 con cuatro columnas (product_id, product_name, category, unit_cost, nada más), cargada primero con V1 (P002=snacks/0.60), con el snapshot_id de esa carga capturado en snap_v1:
table.append(pa_table_v1)
snap_v1 = table.current_snapshot().snapshot_id
Y sobrescrita después con V2 (P002=health-snacks/0.68):
table.overwrite(pa_table_v2)
Sin UPDATE, sin INSERT de seguimiento, sin ninguna configuración declarativa: dos llamadas de Python, y el estado anterior sigue disponible, íntegro, con table.scan(snapshot_id=snap_v1).to_arrow(). El módulo 3 verificó, con assert, exactamente los mismos dos números que las dos técnicas anteriores: margin=10.8 (correcto, vía time travel) y margin=9.36 (roto, sin time travel).
Diagrama: tres técnicas, un resultado
Que necesita declarar el ingeniero Filas por producto margin correcto
────────────────────────────────────────────────────────────────────────────────────────────────
1. MERGE (DuckDB) valid_from, valid_to, is_current, 2 (P002 x2) 10.8
el ON con is_current=true,
el INSERT de seguimiento
────────────────────────────────────────────────────────────────────────────────────────────────
2. dbt snapshot strategy, columna updated_at 2 (P002 x2) 10.8
-- ni un UPDATE ni un INSERT escrito
────────────────────────────────────────────────────────────────────────────────────────────────
3. overwrite + nada -- 4 columnas de negocio, 1 (P002 x1, 10.8
time travel cero de historia recuperable via
snapshot_id)
Fíjate en la última columna de la tabla de arriba: margin=10.8 se repite tres veces, sin excepción — no es casualidad, es la prueba de que las tres técnicas resuelven, con caminos completamente distintos, el mismo problema de negocio. Y fíjate en la penúltima: las Técnicas 1 y 2 terminan con dos filas de P002 (una cerrada, una vigente) — el historial vive dentro de la tabla, como filas adicionales. La Técnica 3 termina con una sola fila de P002 — el historial vive fuera de la tabla, en los snapshots que Iceberg archiva automáticamente. Ninguna de las dos formas es "más correcta" en abstracto — la lección 7 de este módulo, y el módulo 7 completo de esta guía, vuelven sobre esta distinción con más detalle.
Por qué este módulo agrega dos técnicas más, si el problema ya está resuelto tres veces
Es una pregunta justa: si table.overwrite() del módulo 3 ya te da el resultado correcto, ¿para qué aprender MERGE INTO y upsert? La respuesta está en cómo llega el cambio, no en el resultado final. Las tres técnicas de esta lección asumen que tú —el ingeniero— ya tienes, en algún lugar, el estado completo y correcto de la tabla nueva (DIM_PRODUCT_V2 en Python, products_v2.csv en dbt): las cuatro filas completas, tres de ellas idénticas a antes. En un pipeline real, lo que normalmente llega no es eso — es un delta: una sola fila de un feed de cambios, una línea nueva en un archivo que un sistema externo exporta hoy, con el precio actualizado de un solo producto. Si tu única herramienta es overwrite(), tienes que reconstruir las cuatro filas completas —incluidas las tres que no cambiaron— antes de poder escribir ni una. MERGE INTO y upsert resuelven exactamente ese caso: les entregas solo lo que cambió, y ellos deciden, fila por fila, si actualizar o insertar — sin que tú tengas que reconstruir nada que ya estaba bien.
Errores comunes
Pensar que las tres técnicas de esta lección dieron resultados distintos, y que solo una de las tres es "la correcta". Qué pasa: alguien, al ver tres herramientas distintas —DuckDB, dbt, PyIceberg—, asume que deben haber producido, en algún detalle, resultados ligeramente diferentes, y busca cuál de los tres números es "el verdadero". Por qué pasa: es intuitivo asumir que herramientas distintas dan resultados distintos, sobre todo si nunca viste los tres números uno al lado del otro. Cómo detectarlo: si terminas esta lección sin poder decir, de memoria, que las tres técnicas dieron margin=10.8, revisa la tabla comparativa de esta lección otra vez. Cómo corregirlo: las tres técnicas resuelven el mismo problema de negocio —Kiosko tiene un solo cambio real de P002, con una sola fecha de vigencia—, así que el resultado correcto es, por definición, el mismo sin importar la herramienta. Lo que varía entre las tres es la mecánica interna —cuántas filas quedan, qué columnas hay que declarar—, nunca el número de negocio final.
Saltarse esta lección porque "ya usé DuckDB y dbt en las guías anteriores, no necesito que me lo repitan". Qué pasa: alguien familiarizado con data-modeling y dbt decide avanzar directo a la lección 3 sin leer esta comparación. Por qué pasa: cada técnica individual ya se sintió resuelta en su momento, así que revisarlas de nuevo se siente redundante. Cómo detectarlo: si no puedes explicar, sin mirar atrás, por qué la Técnica 1 necesitó un INSERT de seguimiento y la Técnica 3 no necesitó ninguno, te falta esta lección — no es una repetición decorativa, es la base de comparación que la lección 7 va a usar para dar criterio real. Cómo corregirlo: esta lección no vuelve a ejecutar nada — cita, literal, el código exacto de cada guía anterior. Ese contraste es lo que hace que MERGE INTO y upsert, en las lecciones que siguen, se sientan como una cuarta y quinta pieza de un mismo rompecabezas, no como herramientas nuevas sin conexión.
Ejercicios
Ejercicio 1 — Completa la tabla comparativa tú mismo. Sin mirar la tabla de esta lección, escribe de memoria: ¿cuántas filas de P002 deja cada una de las tres técnicas al final, y qué necesitó declarar el ingeniero en cada caso?
Ver solución
Técnica 1 (DuckDB MERGE): 2 filas de P002 (una cerrada, una vigente); necesitó declarar valid_from/valid_to/is_current en el esquema, la condición is_current=true en el ON, y un INSERT de seguimiento. Técnica 2 (dbt snapshot): 2 filas de P002; necesitó declarar strategy: timestamp y la columna updated_at, sin escribir ningún UPDATE/INSERT. Técnica 3 (overwrite + time travel): 1 fila de P002; no necesitó declarar ninguna columna de historia — el historial vive en los snapshots, no en filas adicionales.
Ejercicio 2 — Explica, en tus propias palabras, por qué el MERGE INTO de DuckDB necesitó un INSERT de seguimiento y por qué eso va a ser distinto en el MERGE INTO de Iceberg que vas a ver en las lecciones 4 y 5. Pista: piensa en cuántas filas por producto tiene, al final, cada tabla.
Ver solución
El MERGE INTO de DuckDB, en data-modeling, opera sobre una tabla que preserva cada versión histórica como una fila separada (dim_product_scd, SCD tipo 2) — así que "cambiar P002" significa, con precisión, "cerrar la fila vigente Y crear una fila nueva", dos acciones que MERGE INTO en DuckDB no puede hacer para la misma fila de origen en el mismo statement (una fila de origen dispara, como máximo, una rama). El MERGE INTO que vas a usar en Iceberg, en cambio, va a operar sobre dim_product sin ninguna columna de historia — así que "cambiar P002" significa, simplemente, "actualizar la única fila que existe para ese producto", una sola acción (WHEN MATCHED THEN UPDATE), sin necesidad de ningún INSERT de seguimiento. La diferencia no está en MERGE INTO como herramienta — está en si la tabla destino guarda historia como filas (necesita el paso extra) o no la guarda en absoluto (no lo necesita).
Ejercicio 3 — Predicción. Con lo que ya sabes de las tres técnicas de esta lección, predice: ¿qué esperas que reporte table.upsert() de PyIceberg —la quinta técnica, que vas a ejecutar de verdad en la lección 6— sobre cuántas filas actualizó y cuántas insertó, para el cambio real de P002?
Ver solución
Como P002 ya existe en la tabla destino (es un producto conocido, solo cambia su categoría y su costo, no aparece un producto nuevo), lo esperable es que upsert() reporte exactamente una fila actualizada y cero filas insertadas — el mismo patrón "una sola fila cambia" que ya viste en la Técnica 3 de esta lección. La lección 6 confirma esto con evidencia ejecutada real: UpsertResult(rows_updated=1, rows_inserted=0).
Resumen y siguiente paso
En esta lección recorriste, con código citado literal —sin ejecutar nada nuevo—, las tres formas en que Kiosko ya resolvió el cambio de P002: MERGE INTO a mano sobre DuckDB (data-modeling, con INSERT de seguimiento), dbt snapshot automatizado (dbt, sin UPDATE/INSERT explícito), y table.overwrite() + time travel (esta guía, módulo 3, sin ninguna columna de historia). Las tres llegan al mismo resultado de negocio, margin=10.8. Y viste por qué este módulo agrega dos técnicas más: no porque el resultado esté mal, sino porque las tres técnicas de esta lección asumen que ya tienes el estado completo de la tabla — MERGE INTO/upsert resuelven el caso, mucho más común en producción, de un delta parcial.
Antes de avanzar deberías poder: nombrar las tres técnicas, con su guía de origen; explicar por qué la Técnica 1 necesitó un INSERT de seguimiento y las otras dos no; y explicar, con tus propias palabras, la diferencia entre "tener el estado completo" y "tener un delta".
La lección 3 deja atrás la teoría por el resto del módulo: instala de verdad el runtime de Iceberg para Spark, la única pieza de infraestructura nueva que este módulo necesita.
Recursos
data-modeling-for-analytics-guide, lección "Implementando SCD tipo 2 con MERGE INTO" (módulo 4) — fuente literal del código y la salida de la Técnica 1.src/guides/data-modeling-for-analytics-guide/DISENO.md. En español.dbt-analytics-engineering-guide, lección "Cambiando la fuente y snapshoteando de nuevo" (módulo 5) — fuente literal del código y la salida de la Técnica 2.src/guides/dbt-analytics-engineering-guide/DISENO.md. En español.- Esta misma guía, módulo 3, lecciones 2 y 3 — fuente de la Técnica 3 (
table.overwrite()+snap_v1).src/guides/lakehouse-and-iceberg-guide/workbook/module-03-snapshots-and-time-travel/. En español. - DuckDB — documentación oficial del statement
MERGE INTO, la referencia de sintaxis que la Técnica 1 implementa. duckdb.org/docs/lts/sql/statements/merge_into. En inglés. - dbt Developer Hub — "Add snapshots to your DAG", la referencia de
dbt snapshotque la Técnica 2 implementa. docs.getdbt.com/docs/build/snapshots. En inglés. - DISEÑO de esta guía — el mapa completo de los ocho módulos, incluida la sección del módulo 6.
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.