Módulo 6: Merge Into And Native Upserts

Eligiendo entre MERGE en SQL y upsert en Python

Descripción

Este módulo te dio, hasta acá, tres formas de escribir un cambio parcial sobre una tabla Iceberg —table.overwrite() con el estado completo (módulo 3), MERGE INTO vía Spark (lecciones 4 y 5), table.upsert() de PyIceberg (lección 6)— y recordó dos más, fuera de Iceberg (DuckDB, dbt), en la lección 2. Esta lección no agrega ninguna técnica nueva — da el criterio para elegir entre las que ya tienes, con evidencia concreta de esta misma guía, no con una preferencia genérica de "usa lo que te resulte más cómodo".

Conexión con el módulo. Cada lección anterior de este módulo mostró una técnica en aislamiento. Esta lección las pone, las tres de Iceberg, una al lado de la otra, con las diferencias reales que ya viste ejecutar —o intentar ejecutar— en las lecciones 3 a 6.

Una analogía: tres formas de pagar, cada una con su lugar

Piensa en tres formas de pagar una cuenta. Pagar el monto completo, de una —como table.overwrite()— tiene sentido cuando ya sabes exactamente cuánto es el total y te resulta igual de fácil escribirlo entero que escribir solo la diferencia: rápido, sin ambigüedad, pero exige que tengas el número completo a mano. Pedirle al cajero del banco que procese una actualización con un formulario formal —como MERGE INTO— tiene sentido cuando la transacción necesita quedar documentada con una sintaxis clara, auditable, y cuando ya estás parado frente a ese cajero por otro motivo (ya tienes un proceso Spark corriendo). Usar la app del banco para transferir sin ir a ninguna ventanilla —como table.upsert()— tiene sentido cuando quieres resolver todo desde tu propio código, sin depender de que el banco (Spark) esté abierto. Ninguna de las tres es "la correcta" en abstracto — cada una resuelve mejor una situación distinta.

Los cinco criterios, con evidencia de este módulo

1 — ¿Tienes el estado completo, o solo el delta?

table.overwrite() (módulo 3) espera el estado completo — le diste las cuatro filas de V2, con tres de ellas idénticas a V1, porque overwrite() reemplaza el 100% sin excepción. MERGE INTO (lección 5) y table.upsert() (lección 6) están diseñados para el caso contrario: un staging con una sola fila —solo P002— fue suficiente para la lección 5; upsert() de la lección 6 aceptó el estado completo, pero no lo necesitaba — habría funcionado igual con un pa_table de una sola fila. Si tu fuente de datos entrega naturalmente un delta pequeño —un feed de cambios, un export incremental—, MERGE INTO o upsert() evitan que reconstruyas lo que no cambió. Si tu fuente siempre te da el estado completo de una tabla pequeña —como los cuatro productos fijos de Kiosko en Python—, overwrite() sigue siendo la opción más simple, sin ningún costo real.

2 — ¿Necesitas comparar valores, o solo coincidencia de llave?

Acá está la diferencia más sutil, y la que más falló las expectativas en este módulo. MERGE INTO, tal como lo escribiste en la lección 5, actualiza cualquier fila coincidente, sin verificar si algún valor cambió de verdad —el Ejercicio 3 de esa lección lo confirmó: correrlo una segunda vez con los mismos datos sigue reportando una fila "actualizada"—. Para que MERGE INTO sea selectivo, tienes que agregar la condición a mano, como hizo data-modeling con DuckDB: WHEN MATCHED AND (target.unit_cost <> source.unit_cost OR ...). table.upsert(), en cambio, hace esa comparación por ti, internamente, columna por columna —lo confirmaste con evidencia real en la lección 6: una segunda corrida con los mismos datos reporta rows_updated=0, sin que nadie escribiera ninguna condición—.

3 — ¿Necesitas borrar filas, no solo actualizar e insertar?

MERGE INTO, en su sintaxis completa (lección 4), soporta una rama WHEN MATCHED ... THEN DELETE — puedes borrar filas del target como parte del mismo statement. table.upsert() de PyIceberg no tiene ninguna rama de borrado — sus únicas dos acciones posibles son when_matched_update_all y when_not_matched_insert_all (revisa el docstring que citaste en la lección 6); no existe un when_matched_delete. Si tu caso de uso necesita, en la misma operación, borrar productos que ya no existen en la fuente —el escenario de WHEN NOT MATCHED BY SOURCE THEN DELETE que la lección 4 nombró sin implementar—, MERGE INTO en SQL puede resolverlo en un solo statement; upsert() no puede, y necesitarías una llamada separada a table.delete().

4 — ¿Ya tienes un proceso Spark corriendo, o prefieres no depender de la JVM?

Esta es la diferencia que la lección 3 de este módulo dejó más clara que ninguna otra: MERGE INTO necesita una SparkSession viva, con el runtime de Iceberg correctamente emparejado con tu versión exacta de Spark —y ya viste, con evidencia real, que esa combinación de versiones puede no coincidir, y que cuando no coincide, nada corre—. table.upsert() no depende de ninguna de esas dos cosas: corrió sin fricción en la lección 6, en el mismo entorno donde MERGE INTO falló. Si tu organización ya tiene pipelines Spark corriendo a diario —el caso típico de una plataforma de datos con volumen alto—, MERGE INTO se integra naturalmente ahí. Si tu caso es un script Python, un microservicio, o un entorno donde levantar Spark sería una complejidad nueva, upsert() resuelve el mismo problema sin esa dependencia.

5 — ¿A qué escala real vas a operar?

MERGE INTO vía Spark distribuye el trabajo entre varios ejecutores — pensado, precisamente, para un staging o un target de millones de filas, el mismo terreno que spark-and-distributed-processing-guide ya cubrió con fact_orders_at_scale. table.upsert() de PyIceberg corre en un solo proceso Python, sobre PyArrow — perfectamente cómodo para el dim_product de cuatro filas de esta guía, o para dimensiones de miles o incluso millones de filas en una máquina con memoria suficiente, pero sin el paralelismo distribuido de un clúster Spark. Para un hecho (fact_orders_at_scale) con cientos de millones de filas y un delta también grande, MERGE INTO vía Spark es la herramienta que escala; para una dimensión de tamaño moderado, upsert() es más simple, sin sacrificar corrección.

Tabla comparativa: las tres técnicas de Iceberg, lado a lado

Criteriotable.overwrite() (M3)MERGE INTO vía Spark (M6 L4-L5)table.upsert() (M6 L6)
Entrada esperadaEstado completoDelta o estado completoDelta o estado completo
Compara valores automáticamenteNo aplica (reemplaza todo)No — hay que escribirlo en WHEN MATCHED AND (...)Sí, internamente
Soporta DELETE en la misma operaciónNo (usa table.delete() aparte)Sí, WHEN MATCHED THEN DELETENo — no existe esa rama
Necesita Spark / JVMNoNo
Escala a millones de filas distribuidasNo (proceso único)No (proceso único)
Snapshots que produce (verificado)1-2 (delete+append si reemplaza todo)1 (overwrite, verificado en la doc oficial)2 (overwrite parcial + append, verificado en L6)
Verificado en esta guíaEjecutado, módulo 3Representativo, lecciones 4-5Ejecutado, lección 6

Diagrama: el árbol de decisión

flowchart TD
    A["Necesitas escribir un cambio\nsobre una tabla Iceberg"] --> B{"Tenes el estado\nCOMPLETO en memoria,\ny la tabla es chica?"}
    B -->|"Si"| C["table.overwrite()\n-- simple, sin comparar nada"]
    B -->|"No, es un delta"| D{"Ya tenes un\nproceso Spark corriendo,\no necesitas escala distribuida?"}
    D -->|"Si"| E{"Necesitas borrar filas\nen la misma operacion?"}
    E -->|"Si"| F["MERGE INTO en SQL\n-- WHEN MATCHED THEN DELETE"]
    E -->|"No"| F
    D -->|"No, Python puro alcanza"| G["table.upsert()\n-- compara valores solo, sin SQL, sin JVM"]

Lo que ninguna de las tres reemplaza: el límite ya declarado en el módulo 3

Vale la pena cerrar el círculo con algo que el módulo 3 ya dejó explícito, y que sigue siendo cierto acá: ninguna de estas tres técnicas —overwrite(), MERGE INTO, upsert()— resuelve el caso general de una dimensión que cambia varias veces, con hechos repartidos a ambos lados de cada cambio, cuando necesitas preservar todas las versiones históricas visibles como filas consultables con SQL simple (no time travel). Ese caso general sigue siendo terreno de SCD tipo 2 a nivel de fila —a mano en data-modeling, automatizado en dbt—, exactamente la frontera que la lección 7 del módulo 3 ya trazó. Lo que este módulo agregó no es una forma de esquivar esa frontera — es tres formas distintas de aplicar un cambio de forma eficiente y correcta a la tabla vigente, dejando que Iceberg archive el resto en sus propios snapshots.

Errores comunes

Elegir la técnica "más nueva" o "más impresionante" en vez de la que corresponde al caso. Qué pasa: alguien, después de ver MERGE INTO y upsert() funcionando, asume que son "mejores" que table.overwrite() en cualquier situación, y empieza a usarlas incluso para casos triviales donde overwrite() ya alcanzaba. Por qué pasa: las técnicas más recientes que aprendiste se sienten, por instinto, como una mejora universal sobre las anteriores. Cómo detectarlo: si estás escribiendo un MERGE INTO completo con staging y ON para reemplazar cuatro filas fijas que ya tienes en memoria, en Python, sin ninguna necesidad de Spark, estás usando una herramienta más pesada de lo que el problema pide. Cómo corregirlo: vuelve a la tabla comparativa de esta lección — la pregunta correcta nunca es "¿cuál técnica es más avanzada?", es "¿qué tengo disponible (delta o estado completo), y qué infraestructura ya tengo corriendo?".

Asumir que upsert() puede borrar filas, porque "actualizar e insertar" suena a que también debería poder borrar. Qué pasa: alguien busca un argumento como when_not_matched_by_source_delete=True en table.upsert(), esperando que exista, simétrico a when_matched_update_all/when_not_matched_insert_all. Por qué pasa: MERGE INTO sí soporta DELETE, y es natural esperar la misma capacidad en upsert(). Cómo detectarlo: revisa el docstring citado en la lección 6 — las únicas dos acciones documentadas son actualizar coincidencias e insertar lo que no coincide, nunca borrar. Cómo corregirlo: si tu caso necesita borrar filas que ya no aparecen en la fuente, upsert() no es la herramienta — necesitas MERGE INTO con WHEN NOT MATCHED BY SOURCE THEN DELETE, o una llamada separada a table.delete() con el filtro correcto.

Ejercicios

Ejercicio 1 — Completa la tabla comparativa de memoria. Sin mirar esta lección, para cada una de las tres técnicas de Iceberg (overwrite, MERGE INTO, upsert), responde: ¿necesita Spark? ¿compara valores automáticamente? ¿soporta DELETE?

Ver solución

overwrite(): no necesita Spark; no compara valores (reemplaza todo, sin excepción); no tiene DELETE propio (usa table.delete() aparte, fuera del alcance de este módulo). MERGE INTO: sí necesita Spark (y el runtime de Iceberg correctamente emparejado); no compara valores automáticamente, hay que escribirlo a mano en WHEN MATCHED AND (...); sí soporta DELETE en la misma operación. upsert(): no necesita Spark; sí compara valores automáticamente, columna por columna; no soporta DELETE en absoluto.

Ejercicio 2 — Aplica el árbol de decisión a un caso nuevo. Kiosko empieza a recibir, todos los días, un archivo CSV de 50,000 filas con cambios de precios de proveedores externos (no Kiosko en sí, sino un catálogo de referencia mucho más grande). ¿Qué técnica de las tres elegirías, y por qué, usando el árbol de decisión de esta lección?

Ver solución

Con 50,000 filas de delta diario, table.overwrite() queda descartado de entrada —no es un estado completo pequeño en memoria, y reemplazar toda la tabla de referencia por un archivo de 50,000 filas sería, además, incorrecto si la tabla completa tiene más filas que esas—. Entre MERGE INTO y upsert(), la respuesta depende de la infraestructura: si Kiosko ya tiene un pipeline Spark corriendo diariamente para otras cargas (como el de spark-and-distributed-processing-guide), MERGE INTO aprovecha esa infraestructura ya existente y escala sin fricción a volúmenes mayores en el futuro. Si el equipo que mantiene este pipeline específico trabaja en Python puro, sin ningún proceso Spark corriendo para esta tarea, table.upsert() resuelve el mismo problema —50,000 filas es un volumen perfectamente razonable para un solo proceso con PyArrow— sin la complejidad adicional de levantar Spark solo para esto.

Ejercicio 3 — Explica por qué esta lección insiste en que ninguna de las tres técnicas reemplaza el trabajo del módulo 3, lección 7. En 2-3 frases, con tus propias palabras, explica qué problema sigue sin resolver este módulo completo.

Ver solución

Las tres técnicas de este módulo —overwrite(), MERGE INTO, upsert()— resuelven cómo aplicar un cambio a la versión vigente de una tabla, de forma correcta y eficiente. Ninguna resuelve el caso donde necesitas consultar con SQL simple, sin AS OF, todas las versiones históricas de una fila a la vez —por ejemplo, un reporte que junte "el costo de cada producto en el momento exacto de cada venta pasada", sin que quien escribe la consulta tenga que conocer ningún snapshot_id—. Ese caso general sigue necesitando SCD tipo 2 a nivel de fila, con valid_from/valid_to consultables directamente, la misma frontera que el módulo 3, lección 7, ya trazó con evidencia.

Resumen y siguiente paso

En esta lección comparaste las tres técnicas de Iceberg de este módulo —overwrite(), MERGE INTO, upsert()— con cinco criterios concretos, todos respaldados por evidencia que ya generaste en las lecciones anteriores: qué entrada esperan, si comparan valores, si soportan DELETE, si necesitan Spark, y a qué escala operan bien. Ninguna reemplaza a las otras — cada una resuelve mejor un punto de entrada distinto, y ninguna resuelve el caso general de SCD tipo 2 consultable con SQL simple, que sigue siendo terreno de data-modeling y dbt.

Antes de avanzar deberías poder: completar la tabla comparativa de memoria; aplicar el árbol de decisión a un caso nuevo; y explicar qué sigue sin resolver este módulo completo, incluso después de aprender las cinco técnicas.

La lección 8 integra el resultado ejecutable de este módulo en un solo proyecto: table.upsert() de punta a punta, con assert automáticos, y el MERGE INTO de Spark documentado como referencia representativa junto a él.

Recursos

  • Apache Iceberg — documentación oficial, "Spark Writes", sección MERGE INTO, fuente de la rama DELETE que upsert() no tiene. iceberg.apache.org/docs/latest/spark-writes/#merge-into. En inglés.
  • PyIceberg — referencia de API, table.upsert(), fuente del docstring con las cuatro combinaciones de when_matched_update_all/when_not_matched_insert_all, sin ninguna opción de DELETE. py.iceberg.apache.org/api. En inglés.
  • Esta misma guía, módulo 3, lección 7, "Qué NO reemplaza el time travel" — la frontera que esta lección confirma que sigue vigente. 07-what-time-travel-does-not-replace.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.