Módulo 6: Merge Into And Native Upserts
Configurando Spark con el runtime de Iceberg
Descripción
Esta lección deja la teoría atrás por el resto del módulo: instala, de verdad, la pieza que le falta a un Spark normal para hablar con tablas Iceberg —el runtime de Iceberg para Spark—, y confirma, con evidencia real y no con una promesa, exactamente hasta dónde llega esa instalación en el entorno de esta guía. Vas a ver el jar correcto descargarse desde Maven Central, la SparkSession arrancar con la versión exacta que esperas, y también vas a ver un error real, documentado, que existe en el propio ecosistema de Iceberg en este momento — y por qué ese error, lejos de ser un fracaso de esta lección, es exactamente el tipo de evidencia que esta guía promete mostrar en vez de esconder.
Conexión con el módulo. La lección 1 declaró, de forma explícita, que este es el único módulo de toda la guía que necesita la JVM. Esta lección cumple esa promesa: instala Spark con el runtime de Iceberg, reutilizando el PySpark 4.2.0 + Java 17 que spark-and-distributed-processing-guide ya dejó configurado. Las lecciones 4 y 5 construyen sobre exactamente lo que esta lección deja funcionando —y sobre exactamente lo que esta lección descubre que no funciona todavía—.
Una analogía: el traductor que habla tu idioma, no el banco
Retomando la analogía del módulo: el mostrador del banco (MERGE INTO) existe, pero necesita que alguien lo atienda con el idioma correcto. Spark, por sí solo, no sabe leer una tabla Iceberg —entiende Parquet como archivo suelto, pero no entiende la cadena metadata → manifest list → manifest files → data files que el módulo 2 de esta guía ya mapeó—. El runtime de Iceberg para Spark es el traductor que se contrata para esa ventanilla específica: un paquete de código, publicado por el propio proyecto Apache Iceberg, que le enseña a Spark a leer y escribir el formato de tabla completo, no solo los archivos Parquet sueltos que hay adentro.
Paso 1 — Verifica el entorno heredado: Java 17 y PySpark
spark-and-distributed-processing-guide (módulo 1, lección 4) ya dejó Java 17 y PySpark instalados y verificados. Confírmalo en tu propia máquina antes de seguir:
java -version
echo $JAVA_HOME
python3 -c "import pyspark; print('pyspark', pyspark.__version__)"
Qué esperar (verificado en esta máquina; la ruta exacta de JAVA_HOME es específica de este equipo —macOS, instalado con Homebrew— y va a verse distinta en la tuya, exactamente la misma advertencia que hizo spark-and-distributed-processing-guide en su momento):
openjdk version "17.0.20" 2026-07-21
OpenJDK Runtime Environment Homebrew (build 17.0.20+0)
OpenJDK 64-Bit Server VM Homebrew (build 17.0.20+0, mixed mode, sharing)
/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
pyspark 4.2.0
Si tu JAVA_HOME viene vacío, o tu versión de Java es menor a 17, vuelve a la lección 4 del módulo 1 de spark-and-distributed-processing-guide — esta lección no repite esa instalación, la da por hecha.
Paso 2 — Verifica la coordenada exacta del runtime en Maven Central
Antes de escribir una sola línea de configuración, confirma que la coordenada org.apache.iceberg:iceberg-spark-runtime-4.0_2.13:1.11.0 es real y vigente — el DISEÑO de esta guía advierte, explícitamente, que estos artefactos rotan con cada release de Iceberg, así que no se toma como dada:
curl -s https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-spark-runtime-4.0_2.13/maven-metadata.xml
Qué esperar (verificado contra Maven Central real al escribir esta lección):
<metadata>
<groupId>org.apache.iceberg</groupId>
<artifactId>iceberg-spark-runtime-4.0_2.13</artifactId>
<versioning>
<latest>1.11.0</latest>
<release>1.11.0</release>
<versions>
<version>1.10.0</version>
<version>1.10.1</version>
<version>1.10.2</version>
<version>1.11.0</version>
</versions>
<lastUpdated>20260519045253</lastUpdated>
</versioning>
</metadata>
Confirmado: 1.11.0 es, en este momento, la versión latest y release de iceberg-spark-runtime-4.0_2.13 — la misma coordenada que el DISEÑO de esta guía anticipó, ahora verificada contra el repositorio real, no asumida. 4.0_2.13 significa: compilado para Spark 4.0, con Scala 2.13 — la misma versión de Scala que trae pyspark==4.2.0 (puedes confirmarlo listando los .jar de tu instalación de PySpark: spark-core_2.13-4.2.0.jar, spark-sql_2.13-4.2.0.jar). Fíjate en el número de Spark del artefacto —4.0— comparado con el número de tu PySpark instalado —4.2.0—: no son el mismo número. Guarda esa observación; el resto de esta lección explica por qué importa.
Paso 3 — Arma la SparkSession con el runtime de Iceberg
# spark_iceberg_session.py
import os
os.environ["JAVA_HOME"] = "/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home" # tu ruta puede variar
from pyspark.sql import SparkSession
warehouse_path = os.path.abspath("kiosko_spark_warehouse")
spark = (
SparkSession.builder
.appName("kiosko-iceberg-merge")
.config("spark.jars.packages", "org.apache.iceberg:iceberg-spark-runtime-4.0_2.13:1.11.0")
.config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.sql.catalog.local", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.local.type", "hadoop")
.config("spark.sql.catalog.local.warehouse", f"file://{warehouse_path}")
.getOrCreate()
)
print("SPARK VERSION:", spark.version)
Cada línea de .config(...) viene, literal, de la documentación oficial de Iceberg para Spark —no son valores inventados para esta guía—: spark.jars.packages es el mecanismo estándar de Spark para bajar una dependencia de Maven Central en el momento de arrancar, sin tener que instalar nada a mano de antemano; spark.sql.extensions habilita la sintaxis SQL adicional que Iceberg agrega a Spark (incluido MERGE INTO sobre tablas Iceberg); spark.sql.catalog.local con type=hadoop registra un catálogo llamado local —el mismo nombre que usa, textual, la documentación oficial de "Getting Started" de Iceberg— respaldado únicamente por el filesystem, sin ninguna base de datos externa.
Detente en el nombre local. Es la misma convención literal de la documentación oficial de Iceberg, y esta guía la respeta a propósito — pero es un catálogo completamente distinto del catálogo kiosko que PyIceberg usó en los módulos 1 a 5: distinto mecanismo de registro (hadoop, basado en archivos, contra sql/SQLite), distinto directorio de warehouse (kiosko_spark_warehouse/ contra kiosko_warehouse/). Cualquier tabla local.kiosko.dim_product que crees en este módulo es una tabla nueva, independiente de kiosko.dim_product — nunca la misma tabla vista desde dos motores.
Paso 4 — Corre la sesión: lo que sí funciona
python3 spark_iceberg_session.py
Qué esperar (verificado corriendo el script real, con red disponible hacia Maven Central):
:: loading settings :: url = jar:file:/.../pyspark/jars/ivy-2.5.3.jar!/org/apache/ivy/core/settings/ivysettings.xml
Ivy Default Cache set to: /Users/.../.ivy2.5.2/cache
The jars for the packages stored in: /Users/.../.ivy2.5.2/jars
org.apache.iceberg#iceberg-spark-runtime-4.0_2.13 added as a dependency
:: resolving dependencies :: org.apache.spark#spark-submit-parent-...;1.0
confs: [default]
found org.apache.iceberg#iceberg-spark-runtime-4.0_2.13;1.11.0 in central
downloading https://repo1.maven.org/maven2/org/apache/iceberg/iceberg-spark-runtime-4.0_2.13/1.11.0/iceberg-spark-runtime-4.0_2.13-1.11.0.jar ...
[SUCCESSFUL ] org.apache.iceberg#iceberg-spark-runtime-4.0_2.13;1.11.0!iceberg-spark-runtime-4.0_2.13.jar (1328ms)
:: resolution report :: resolve 394ms :: artifacts dl 1331ms
1 artifacts copied, 0 already retrieved (46803kB/34ms)
Setting default log level to "WARN".
SPARK VERSION: 4.2.0
Esto es real, no representativo: el .jar de iceberg-spark-runtime-4.0_2.13-1.11.0 —46.8 MB— se descargó de verdad desde Maven Central, spark.jars.packages funcionó exactamente como documenta Iceberg, y la SparkSession arrancó con la versión exacta de PySpark que esta guía hereda: 4.2.0. Hasta acá, todo funciona.
Paso 5 — Lo que no funciona, todavía, con evidencia real
El siguiente paso natural sería crear el namespace kiosko dentro del catálogo local y empezar a trabajar. Corre esa línea, sobre la misma sesión:
spark.sql("CREATE NAMESPACE IF NOT EXISTS local.kiosko")
Qué esperar (el error real, verificado, no inventado — versión abreviada del traceback completo):
Traceback (most recent call last):
...
py4j.protocol.Py4JJavaError: An error occurred while calling o40.sql.
: java.lang.IncompatibleClassChangeError: class org.apache.iceberg.spark.source.SparkView
can not implement org.apache.spark.sql.connector.catalog.View, because it is not an
interface (org.apache.spark.sql.connector.catalog.View is in unnamed module of loader 'app')
at java.base/java.lang.ClassLoader.defineClass1(Native Method)
...
at org.apache.spark.sql.connector.catalog.Catalogs$.load(Catalogs.scala:65)
at org.apache.spark.sql.connector.catalog.DefaultCatalogManager.$anonfun$catalog$1(CatalogManager.scala:134)
...
Y no es un caso aislado de CREATE NAMESPACE: el mismo error aparece con cualquier operación sobre el catálogo local, incluida una tan simple como SHOW NAMESPACES IN local — el error ocurre al cargar la clase del catálogo en sí, antes de que la operación específica importe.
Por qué pasa esto, y por qué se documenta en vez de esconderse
Este no es un error de configuración de esta lección —Java 17 está bien instalado, JAVA_HOME apunta al lugar correcto, la coordenada de Maven es la exacta que el DISEÑO de esta guía pidió verificar—. Es una incompatibilidad binaria real, y documentada, entre iceberg-spark-runtime-4.0 y Spark 4.1 en adelante. El propio repositorio de Apache Iceberg en GitHub tiene un issue abierto sobre exactamente esta familia de problema: apache/iceberg#15238, "Spark 4.1 incompatible: Create View", abierto el 5 de febrero de 2026 y todavía abierto al momento de escribir esta lección. La causa raíz que describe ese issue: Spark 4.1 le agregó un tercer parámetro a la clase interna ResolvedIdentifier (output: Seq[Attribute] = Nil), un cambio que rompe compatibilidad binaria con el código que Iceberg compiló contra la firma anterior de Spark 4.0 — la misma familia de ruptura que produce el IncompatibleClassChangeError que ves arriba, sobre la clase SparkView.
Y hay una segunda pieza de evidencia, verificable con el Paso 2 de esta misma lección: al momento de escribir esta guía, iceberg-spark-runtime no publica ninguna variante 4.1 ni 4.2 en Maven Central — solo 4.0_2.13 existe para la familia Spark 4.x. pyspark==4.2.0, el que esta guía hereda de spark-and-distributed-processing-guide, ya está dos versiones menores por delante del runtime de Iceberg más reciente disponible. No es un error de esta guía ni de tu máquina — es el estado real del ecosistema de artefactos de Iceberg para Spark en este momento, exactamente la advertencia que el DISEÑO de esta guía anticipó: "estos artefactos rotan con cada release".
La consecuencia práctica para el resto de este módulo, declarada así de explícita: las lecciones 4 y 5 muestran el MERGE INTO real, con la sintaxis exacta que ejecutarías si tu combinación de versiones de Spark e Iceberg fuera compatible, pero su salida se marca como "Qué esperar (representativo)" — verificada contra la documentación oficial de Iceberg, no contra una corrida real en este entorno. La lección 6, en cambio, sí corre de verdad: table.upsert() de PyIceberg no depende de Spark ni de esta incompatibilidad, así que su salida es literal, ejecutada, sin ninguna reserva.
Diagrama: hasta dónde llegó esta lección
flowchart TD
A["Java 17 + JAVA_HOME\nverificado, funciona"] --> B["pyspark 4.2.0\nverificado, funciona"]
B --> C["spark.jars.packages descarga\niceberg-spark-runtime-4.0_2.13:1.11.0\nverificado, funciona"]
C --> D["SparkSession arranca\nspark.version == 4.2.0\nverificado, funciona"]
D --> E["spark.sql('CREATE NAMESPACE ...')\nIncompatibleClassChangeError\nREAL, documentado en iceberg#15238"]
E -.->|"leccion 4 y 5"| F["MERGE INTO: sintaxis real,\nsalida 'Que esperar (representativo)'"]
E -.->|"leccion 6, sin Spark"| G["table.upsert(): ejecutado\nde verdad, sin reserva"]
Errores comunes
Asumir que una coordenada iceberg-spark-runtime-X_Y funciona con cualquier versión de Spark que empiece con el mismo número mayor. Qué pasa: alguien ve iceberg-spark-runtime-4.0_2.13 y pyspark==4.2.0, y asume que "4.0" y "4.2" son compatibles porque ambos son "Spark 4". Por qué pasa: en muchas librerías de Python, un cambio de versión menor (4.0 a 4.2) suele ser compatible hacia atrás sin fricción. Cómo detectarlo: si tu MERGE INTO o cualquier operación de catálogo Iceberg falla con IncompatibleClassChangeError o NoSuchMethodError mencionando clases de org.apache.spark.sql.connector.catalog, sospecha primero de un desajuste de versión entre el runtime de Iceberg y tu Spark exacto, antes de revisar tu propio código SQL. Cómo corregirlo: verifica siempre, como hizo el Paso 2 de esta lección, qué variantes de iceberg-spark-runtime existen en Maven Central para tu versión exacta de Spark — si no existe una que coincida exactamente, como es el caso de Spark 4.2.0 en este momento, la opción real es usar una versión de Spark que sí tenga runtime compatible (4.0.x), o esperar a que Iceberg publique la variante correspondiente.
Confundir este error con un problema de Java o de JAVA_HOME. Qué pasa: alguien ve un traceback largo, con clases de Java en cada línea, y asume que el problema es la instalación de Java —vuelve a revisar JAVA_HOME, reinstala el JDK, sin resultado—. Por qué pasa: cualquier error que muestre java.lang.* en la primera línea se siente, por instinto, como un problema de la instalación de Java en sí. Cómo detectarlo: fíjate en el Paso 4 de esta lección — la SparkSession arrancó sin ningún problema, con spark.version confirmado, antes de que apareciera el error. Si tu sesión arranca bien y el error aparece recién al ejecutar SQL sobre el catálogo Iceberg, Java está bien instalado — el problema es la incompatibilidad binaria entre versiones de librerías, no tu JDK. Cómo corregirlo: no reinstales Java — confirma, como hizo esta lección, que el error ocurre específicamente al cargar clases de org.apache.iceberg.spark.*, y trátalo como lo que es: un desajuste de versiones entre Iceberg y Spark, documentado en apache/iceberg#15238.
Dar por sentado que un error real invalida el resto del módulo. Qué pasa: alguien, al ver que MERGE INTO no corre de verdad en este entorno, asume que las lecciones 4 y 5 no tienen valor, o que deberían saltarse. Por qué pasa: es fácil pensar que una lección técnica solo vale si el código corrió sin errores. Cómo detectarlo: si tu reacción a esta lección es "entonces esta parte de la guía no sirve", relee la sección anterior — la sintaxis exacta de MERGE INTO que las lecciones 4 y 5 enseñan es real, verificada contra la documentación oficial, y es exactamente lo que correrías en un entorno con versiones compatibles (por ejemplo, Spark 4.0.x con este mismo runtime). Cómo corregirlo: trata el error de esta lección como lo que es — evidencia real de que los artefactos de un ecosistema activo rotan y a veces se desalinean, algo que vas a encontrar en cualquier stack de datos real. La sintaxis que aprendes en las lecciones 4 y 5 sigue siendo correcta y aplicable el día que tu combinación de versiones sí sea compatible.
Ejercicios
Ejercicio 1 — Reproduce la verificación de Maven Central tú mismo. Corre el comando curl del Paso 2 de esta lección. Confirma que 1.11.0 sigue siendo la versión latest, o identifica si ya salió una versión más nueva.
Ver solución
Si corriste este ejercicio después de la fecha en que se escribió esta guía, es posible que veas una versión más nueva que 1.11.0 en el campo <latest> — Iceberg publica releases con regularidad. Eso no invalida esta lección: revisa si esa versión más nueva ya incluye una variante 4.1_2.13 o 4.2_2.13 de iceberg-spark-runtime — si es así, la incompatibilidad de esta lección podría estar resuelta para tu combinación de versiones, y valdría la pena repetir el Paso 4 con la coordenada actualizada.
Ejercicio 2 — Explica, sin mirar la lección, la diferencia entre "la sesión de Spark arranca" y "el catálogo Iceberg funciona". En 2-3 frases, explica por qué el Paso 4 de esta lección tuvo éxito (spark.version confirmado) mientras el Paso 5 falló, aunque los dos corran sobre la misma SparkSession.
Ver solución
Arrancar una SparkSession con spark.jars.packages solo necesita que Spark descargue el .jar y lo agregue a su classpath — ese paso no ejecuta ningún código específico de Iceberg todavía, así que puede tener éxito aunque el contenido del .jar sea incompatible. El error recién aparece cuando Spark intenta cargar y usar una clase específica del runtime de Iceberg —como SparkCatalog, al resolver local.kiosko—, momento en el que la JVM verifica, en tiempo de ejecución, que las clases sean binariamente compatibles entre sí. IncompatibleClassChangeError es, con precisión, el error que la JVM lanza cuando esa verificación falla — nunca antes, porque hasta ese momento nadie le había pedido a la JVM que usara esa clase específica.
Ejercicio 3 — Predicción. Con la evidencia de esta lección, predice: si instalaras pyspark==4.0.0 en vez de pyspark==4.2.0 (una versión exacta que sí coincide con iceberg-spark-runtime-4.0_2.13), ¿esperas que el Paso 5 de esta lección funcione sin el IncompatibleClassChangeError?
Ver solución
Es razonable esperar que sí —la coordenada del runtime dice, explícitamente, 4.0, y el issue apache/iceberg#15238 describe la ruptura como algo introducido específicamente en Spark 4.1, no presente en Spark 4.0—. Esta guía no verifica esa combinación exacta, porque hacerlo significaría reinstalar una versión de Spark distinta de la que el resto del ecosistema (spark-and-distributed-processing-guide) ya dejó configurada, rompiendo la reutilización de entorno que el DISEÑO de esta guía pide explícitamente. Queda como una hipótesis verificable, no como un hecho confirmado en esta lección.
Resumen y siguiente paso
En esta lección instalaste, de verdad, el entorno que este módulo necesita: confirmaste Java 17 y PySpark 4.2.0 heredados de spark-and-distributed-processing-guide, verificaste la coordenada exacta de iceberg-spark-runtime-4.0_2.13:1.11.0 contra Maven Central real, viste el .jar descargarse y la SparkSession arrancar con éxito — y también viste, con un traceback real, una incompatibilidad binaria documentada (apache/iceberg#15238) entre ese runtime y Spark 4.1 en adelante, que hace que las próximas dos lecciones presenten su MERGE INTO como representativo en vez de ejecutado.
Antes de avanzar deberías poder: explicar la diferencia entre "la sesión arranca" y "el catálogo funciona"; nombrar la causa raíz exacta de la incompatibilidad (el cambio de firma de ResolvedIdentifier en Spark 4.1); y explicar por qué esta lección documenta el error en vez de omitirlo.
La lección 4 usa exactamente este entorno —incompatibilidad incluida, declarada así de explícita— para enseñar la sintaxis general de MERGE INTO sobre Iceberg, verificada contra la documentación oficial.
Recursos
- Apache Iceberg — documentación oficial, "Getting Started", sección "Using Iceberg in Spark" — fuente literal de la convención del catálogo
localyspark.jars.packages. iceberg.apache.org/docs/latest/getting-started. En inglés. - Apache Iceberg — documentación oficial, "Spark Configuration" — fuente de
spark.sql.catalog.<name>.type=hadoopyspark.sql.extensions. iceberg.apache.org/docs/latest/spark-configuration. En inglés. - Maven Central —
iceberg-spark-runtime-4.0_2.13, metadata verificada en el Paso 2 de esta lección. repo1.maven.org/maven2/org/apache/iceberg/iceberg-spark-runtime-4.0_2.13. En inglés. - GitHub —
apache/iceberg#15238, "Spark 4.1 incompatible: Create View" — el issue real y abierto que documenta la causa raíz del error de esta lección. github.com/apache/iceberg/issues/15238. En inglés. - DISEÑO de
spark-and-distributed-processing-guide— fuente del entorno PySpark 4.2.0 + Java 17 que esta lección reutiliza.src/guides/spark-and-distributed-processing-guide/DISENO.md. En español. - DISEÑO de esta guía — la advertencia explícita sobre la rotación de artefactos de Iceberg, y la regla de verificarlos al escribir cada lección.
src/guides/lakehouse-and-iceberg-guide/DISENO.md. En español.