Módulo 6: Merge Into And Native Upserts

`MERGE INTO` en Spark SQL

Descripción

Esta lección enseña la sintaxis de MERGE INTO sobre una tabla Iceberg, verificada literal contra la documentación oficial de Apache Iceberg — la misma referencia que el DISEÑO de esta guía cita como base de este módulo. No aplica todavía el statement al caso concreto de P002 —eso es el trabajo de la lección 5—; esta lección se queda con la estructura general, la misma que vas a reconocer, con pequeños ajustes, en cualquier motor SQL moderno que soporte MERGE.

Conexión con el módulo. La lección 3 dejó algo pendiente, declarado sin rodeos: el entorno de esta guía no puede ejecutar MERGE INTO de verdad contra el catálogo local, por la incompatibilidad real entre iceberg-spark-runtime-4.0 y Spark 4.1 en adelante. Esta lección respeta esa limitación — todo el SQL de acá está marcado como "Qué esperar (representativo)", verificado contra la documentación oficial, no ejecutado en este entorno. La lección 5 aplica esta misma sintaxis al caso real de Kiosko, con la misma etiqueta.

Una analogía: el formulario del mostrador, con sus casillas fijas

El mostrador del banco de la lección 1 tiene un formulario con una estructura fija, sin importar qué trámite estés haciendo: primero identificas la cuenta (ON), después declaras qué hacer si la cuenta ya existe (WHEN MATCHED), y por último qué hacer si no existe (WHEN NOT MATCHED). MERGE INTO es, con precisión, ese formulario: una estructura de tres partes, siempre en el mismo orden, que cualquier motor SQL moderno —DuckDB, Spark, Snowflake, BigQuery— implementa con la misma forma general, aunque los detalles finos varíen de motor a motor.

La sintaxis, citada literal de la documentación oficial de Iceberg

La documentación oficial de "Spark Writes" describe MERGE INTO con esta estructura exacta:

MERGE INTO prod.db.target t   -- a target table
USING (SELECT ...) s          -- the source updates
ON t.id = s.id                -- condition to find updates for target rows
WHEN ...                      -- updates

Tres piezas, en orden fijo. El target —la tabla que vas a modificar— y el source —la consulta que trae las filas nuevas o cambiadas—, unidas por una condición ON que funciona exactamente como la condición de un JOIN: para cada fila del source, busca si existe una fila del target que coincida.

WHEN MATCHED — actualizar (o borrar) lo que ya existe

WHEN MATCHED AND s.op = 'delete' THEN DELETE
WHEN MATCHED AND t.count IS NULL AND s.op = 'increment' THEN UPDATE SET t.count = 0
WHEN MATCHED AND s.op = 'increment' THEN UPDATE SET t.count = t.count + 1

Fíjate en algo importante, citado literal de la documentación: puedes tener varias cláusulas WHEN MATCHED, cada una con su propia condición adicional, y "la primera expresión que coincide se usa" — el orden en que las escribes importa, exactamente como una cadena de if/elif en Python.

WHEN NOT MATCHED — insertar lo que no existía

WHEN NOT MATCHED THEN INSERT *

INSERT * inserta todas las columnas del source, tal cual, para cualquier fila que no haya encontrado coincidencia en el target. También soporta condiciones adicionales y listas explícitas de columnas:

WHEN NOT MATCHED AND s.event_time > still_valid_threshold THEN INSERT (id, count) VALUES (s.id, 1)

WHEN NOT MATCHED BY SOURCE — lo que existe en el destino pero no en la fuente

Spark 3.5 agregó una tercera rama, que Kiosko en este módulo no necesita, pero vale la pena nombrar porque un catálogo de producción real casi siempre la necesita:

WHEN NOT MATCHED BY SOURCE THEN UPDATE SET status = 'invalid'

Esta rama resuelve el caso contrario a WHEN NOT MATCHED: filas que ya estaban en el target, pero que el source de esta corrida no menciona en absoluto —por ejemplo, si Kiosko descontinuara P004 por completo y dejara de incluirlo en cualquier exportación futura del catálogo—. Esta guía no la implementa en la lección 5 porque el catálogo de Kiosko, en todo este módulo, sigue teniendo exactamente los mismos cuatro productos — ninguno se agrega, ninguno se descontinúa —, así que esa rama nunca se activaría con los datos de este ejercicio.

Una regla dura, distinta de overwrite(): solo una coincidencia por fila

La documentación es explícita sobre algo que vale la pena marcar en rojo: "Only one record in the source data can update any given row of the target table, or else an error will be thrown". Si tu source tuviera, por error, dos filas con el mismo product_id, MERGE INTO no elige una al azar ni las combina — falla, con un error explícito. Esta es la misma disciplina de grano —una fila por producto— que ya viste en el módulo 3: dim_product nunca debería tener dos candidatos para el mismo product_id en una sola corrida.

Comparado con el MERGE INTO de DuckDB que recordaste en la lección 2

Fíjate en algo que cambia, y en algo que no cambia, comparado con el MERGE INTO de DuckDB sobre dim_product_scd:

Lo que no cambia: la estructura de tres partes —target/source/ON— y el vocabulario WHEN MATCHED/WHEN NOT MATCHED son prácticamente idénticos entre los dos motores. Si sabes escribir un MERGE INTO en DuckDB, ya sabes leer uno en Spark.

Lo que sí cambia, y es la diferencia central de este módulo: el MERGE de DuckDB, en data-modeling, necesitó un INSERT de seguimiento aparte, porque dim_product_scd preserva cada versión histórica como una fila —"cambiar P002" ahí significaba cerrar una fila y abrir otra, dos acciones que un MERGE no puede hacer para la misma fila de origen en un solo statement—. El MERGE INTO que vas a aplicar en la lección 5, sobre una tabla Iceberg sin ninguna columna de historia, resuelve el cambio completo con una sola rama: WHEN MATCHED THEN UPDATE, sin ningún paso adicional. El historial, en este caso, no vive en filas nuevas — vive en los snapshots que Iceberg archiva automáticamente, por fuera de la tabla, exactamente el mismo principio que el módulo 3 completo ya estableció.

Diagrama: la anatomía de un MERGE INTO

flowchart TD
    T["target\n(la tabla que se modifica)"] --> ON{"ON t.id = s.id\n(como la condicion de un JOIN)"}
    S["source\n(las filas nuevas o cambiadas)"] --> ON
    ON -->|"coincide"| WM["WHEN MATCHED ...\nUPDATE o DELETE\n(la primera condicion que aplica gana)"]
    ON -->|"no coincide, existe solo en source"| WNM["WHEN NOT MATCHED ...\nINSERT"]
    ON -->|"no coincide, existe solo en target\n(Spark 3.5+, no usado en Kiosko)"| WNMBS["WHEN NOT MATCHED BY SOURCE ...\nUPDATE o DELETE"]

Por qué esta lección no puede mostrar salida ejecutada

Cualquier MERGE INTO real, sobre una tabla Iceberg, produce un snapshot nuevo cuando corre con éxito — la documentación oficial incluso especifica, desde Spark 4.1 en adelante, un conjunto de campos que el resumen de ese snapshot puede incluir: spark.merge-into.num-target-rows-updated, spark.merge-into.num-target-rows-inserted, entre otros, contando con precisión cuántas filas tocó cada rama. Esta guía no puede mostrarte esos números como salida literal, por la razón exacta que la lección 3 documentó con evidencia: el entorno de esta guía (iceberg-spark-runtime-4.0_2.13 contra pyspark==4.2.0) no logra ejecutar ninguna operación de catálogo Iceberg todavía. Todo el SQL de esta lección está verificado, línea por línea, contra la documentación oficial vigente — pero ninguna salida de esta lección es una corrida real. La lección 5 aplica esta sintaxis al caso concreto de P002, con la misma etiqueta explícita.

Errores comunes

Escribir WHEN MATCHED THEN UPDATE SET * esperando que actualice todas las columnas, igual que INSERT *. Qué pasa: alguien, familiarizado con INSERT * de la sección WHEN NOT MATCHED, asume que existe un UPDATE SET * equivalente para actualizar todas las columnas sin listarlas. Por qué pasa: la simetría entre INSERT y UPDATE parece natural. Cómo detectarlo: si tu MERGE INTO falla con un error de sintaxis en UPDATE SET *, revisa la sintaxis exacta citada en esta lección — UPDATE SET siempre necesita una lista explícita de asignaciones (columna = valor), columna por columna. Cómo corregirlo: escribe cada asignación de forma explícita —UPDATE SET t.category = s.category, t.unit_cost = s.unit_cost—, exactamente como vas a ver en la lección 5 para el caso de dim_product.

Asumir que WHEN NOT MATCHED BY SOURCE es obligatoria en cualquier MERGE INTO. Qué pasa: alguien, después de leer sobre las tres ramas posibles, agrega WHEN NOT MATCHED BY SOURCE a su MERGE "por completitud", sin necesitarla realmente. Por qué pasa: ver tres opciones documentadas hace sentir que las tres son igual de necesarias. Cómo detectarlo: si tu MERGE incluye una rama WHEN NOT MATCHED BY SOURCE que nunca se activa —porque tu source siempre incluye todas las filas relevantes del target—, esa rama es código muerto que nadie va a ejercitar. Cómo corregirlo: agrega WHEN NOT MATCHED BY SOURCE solo cuando tu caso de negocio real lo necesite —productos descontinuados, cuentas cerradas—; el caso de Kiosko en este módulo, con un catálogo de cuatro productos fijos, no la necesita, y la lección 5 no la incluye.

Ejercicios

Ejercicio 1 — Reescribe, de memoria, la estructura de tres partes de MERGE INTO. Sin mirar esta lección, escribe el esqueleto general —MERGE INTO ... USING ... ON ... WHEN ...— con tus propias palabras describiendo qué va en cada parte.

Ver solución
MERGE INTO <tabla_destino> AS target
USING <fuente_de_cambios> AS source
ON <condicion_de_coincidencia, como un JOIN>
WHEN MATCHED THEN UPDATE SET <columna = valor, ...>
WHEN NOT MATCHED THEN INSERT (<columnas>) VALUES (<valores>)

target es la tabla que existe y se va a modificar; source es la consulta o tabla que trae las filas nuevas o cambiadas; ON decide, fila por fila, si hay coincidencia; WHEN MATCHED actualiza (o borra) lo que ya existía; WHEN NOT MATCHED inserta lo que no existía.

Ejercicio 2 — Explica por qué MERGE INTO en Iceberg, sobre dim_product, no necesita un INSERT de seguimiento como el de DuckDB. En 2-3 frases, usando lo que ya sabes del módulo 3, explica la diferencia.

Ver solución

El MERGE de DuckDB necesitaba un INSERT de seguimiento porque dim_product_scd preserva cada versión histórica como una fila separada —cambiar P002 significaba cerrar una fila y abrir otra, dos acciones que un solo MERGE no puede hacer para la misma fila de origen—. La tabla Iceberg dim_product de esta guía no tiene ninguna columna de historia: una fila por producto, siempre. "Cambiar P002" ahí significa, simplemente, actualizar los valores de la única fila que existe para ese producto — una sola rama, WHEN MATCHED THEN UPDATE, sin ningún paso adicional. El historial vive en los snapshots de Iceberg, no en filas extra de la tabla.

Ejercicio 3 — Predicción. Con la sintaxis de esta lección, escribe (sin correrlo — no hay forma de correrlo en este entorno) el MERGE INTO que aplicaría el cambio de P002 sobre local.kiosko.dim_product, usando local.kiosko.dim_product_staging como source. No mires la lección 5 todavía.

Ver solución
MERGE INTO local.kiosko.dim_product AS target
USING local.kiosko.dim_product_staging AS source
ON target.product_id = source.product_id
WHEN MATCHED THEN UPDATE SET
    target.product_name = source.product_name,
    target.category = source.category,
    target.unit_cost = source.unit_cost
WHEN NOT MATCHED THEN INSERT (product_id, product_name, category, unit_cost)
VALUES (source.product_id, source.product_name, source.category, source.unit_cost)

La lección 5 usa exactamente esta estructura, con el detalle adicional de qué contiene dim_product_staging en el caso concreto de Kiosko.

Resumen y siguiente paso

En esta lección aprendiste la sintaxis general de MERGE INTO sobre Iceberg, citada literal de la documentación oficial: la estructura de tres partes (target/source/ON), las tres ramas posibles (WHEN MATCHED, WHEN NOT MATCHED, WHEN NOT MATCHED BY SOURCE), y la regla dura de que solo una fila del source puede coincidir con cada fila del target. Contrastaste esta sintaxis con el MERGE de DuckDB de la lección 2, y viste por qué el caso de Iceberg no necesita ningún INSERT de seguimiento.

Antes de avanzar deberías poder: escribir de memoria el esqueleto de MERGE INTO; explicar la diferencia entre las tres ramas; y explicar por qué esta lección completa está marcada como representativa, no ejecutada.

La lección 5 aplica exactamente esta sintaxis al caso concreto de Kiosko: el cambio real de P002, con local.kiosko.dim_product y una tabla de staging dedicada.

Recursos

  • Apache Iceberg — documentación oficial, "Spark Writes", sección MERGE INTO, fuente literal de toda la sintaxis de esta lección. iceberg.apache.org/docs/latest/spark-writes/#merge-into. En inglés.
  • Apache Iceberg — documentación oficial, "Spark Writes", sección "Snapshot summary" — los campos spark.merge-into.* disponibles desde Spark 4.1. iceberg.apache.org/docs/latest/spark-writes. En inglés.
  • data-modeling-for-analytics-guide, lección "Implementando SCD tipo 2 con MERGE INTO" — el contraste con el MERGE de DuckDB que esta lección cita. src/guides/data-modeling-for-analytics-guide/DISENO.md. En español.
  • Esta misma guía, lección anterior (módulo 6, lección 3) — fuente de la incompatibilidad real que explica por qué esta lección es representativa. 03-setting-up-spark-with-the-iceberg-runtime.md. En español.
  • 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.