MERGE es una única sentencia que ejecuta INSERT, UPDATE o DELETE en una sola pasada, según exista o no una fila coincidente en la tabla de destino. PostgreSQL no lo tuvo hasta la versión 15, así que todo el mundo se apoyaba en INSERT ... ON CONFLICT. Ahora disponemos del MERGE real, conforme al estándar SQL, y cubre casos que ON CONFLICT sencillamente no puede: sincronizar dos tablas, eliminar las filas rezagadas y aplicar acciones distintas bajo condiciones distintas.
Veamos cómo está montado y dónde cae realmente la línea entre MERGE y ON CONFLICT.
Anatomía: destino, origen y ramas WHEN
MERGE toma una tabla de destino (MERGE INTO), un origen (USING) y una condición de unión (ON). Después vienen la rama WHEN MATCHED (la fila coincidió en ON) y la rama WHEN NOT MATCHED (presente en el origen, ausente en el destino).
Tomemos un esquema con una tabla users y un lote entrante users_staging.
MERGE INTO users AS u
USING users_staging AS s
ON u.id = s.id
WHEN MATCHED THEN
UPDATE SET email = s.email,
name = s.name,
updated_at = now()
WHEN NOT MATCHED THEN
INSERT (id, email, name, created_at)
VALUES (s.id, s.email, s.name, now());
Aspectos clave que debes interiorizar:
- El origen puede ser una tabla, una subconsulta o una lista
VALUES.
- Las ramas se evalúan de arriba abajo; se dispara la primera que coincide.
- No puedes referenciar
EXCLUDED dentro de una rama: esa es sintaxis de ON CONFLICT y no existe en MERGE. Simplemente escribe s.email, s.name.
MERGE no devuelve filas en versiones anteriores; RETURNING solo llegó en PostgreSQL 17.
Ramas condicionales y DELETE
La verdadera potencia de MERGE reside en las condiciones AND adicionales de cada rama, sumadas al hecho de que puedes tener varias ramas. Esto es exactamente lo que ON CONFLICT no puede reproducir.
MERGE INTO orders AS o
USING incoming_orders AS i
ON o.order_id = i.order_id
WHEN MATCHED AND i.status = 'cancelled' THEN
DELETE
WHEN MATCHED AND o.amount <> i.amount THEN
UPDATE SET amount = i.amount,
status = i.status,
updated_at = now()
WHEN NOT MATCHED THEN
INSERT (order_id, customer_id, amount, status)
VALUES (i.order_id, i.customer_id, i.amount, i.status);
Tres resultados distintos en una sola sentencia: un pedido cancelado se elimina, uno modificado se actualiza (solo cuando el importe realmente difiere) y uno nuevo se inserta. También puedes añadir WHEN NOT MATCHED ... AND ... para filtrar qué inserciones se producen.
Movimientos útiles:
WHEN MATCHED AND ... THEN DO NOTHING: omitir una fila de forma explícita.
WHEN NOT MATCHED BY SOURCE THEN DELETE: eliminar del destino lo que está ausente en el origen (PostgreSQL 17; perfecto para una sincronización completa).
- El orden de las ramas es el orden de prioridad. Coloca las condiciones estrechas por encima de las amplias.
Sincronización completa de dos tablas
Una tarea clásica: poner una tabla employees en línea con una exportación de un sistema de RR. HH., hr_feed: dar de alta a los recién llegados, actualizar los cambios y dar de baja a los ausentes.
MERGE INTO employees AS e
USING hr_feed AS f
ON e.emp_id = f.emp_id
WHEN MATCHED AND (e.department, e.salary) IS DISTINCT FROM (f.department, f.salary) THEN
UPDATE SET department = f.department,
salary = f.salary
WHEN NOT MATCHED THEN
INSERT (emp_id, full_name, department, salary)
VALUES (f.emp_id, f.full_name, f.department, f.salary)
WHEN NOT MATCHED BY SOURCE THEN
UPDATE SET status = 'terminated';
IS DISTINCT FROM protege contra UPDATEs inútiles cuando nada ha cambiado (y compara NULL correctamente). NOT MATCHED BY SOURCE cubre el tercer ángulo: las filas que existen solo en el destino.
MySQL no tiene un MERGE independiente; se usa INSERT ... ON DUPLICATE KEY UPDATE. ClickHouse toma un camino completamente distinto: inserción más la lógica de ReplacingMergeTree/motor, con la deduplicación diferida a una fusión en segundo plano.
MERGE frente a ON CONFLICT: cuál elegir
Ambos pueden hacer upsert, pero lo resuelven de forma diferente.
ON CONFLICT se dispara ante una violación de un índice único o restricción. Sin índice adecuado, no hay upsert.
MERGE coincide según una condición ON arbitraria. No se requiere un índice (aunque ayuda al rendimiento).
ON CONFLICT es atómico frente a inserciones concurrentes: bajo una condición de carrera, captura el conflicto de forma fiable. MERGE bajo alta concurrencia puede fallar con una violación de unicidad: no hace ninguna magia de skip-locked.
MERGE puede hacer DELETE y ejecutar varias ramas distintas; ON CONFLICT solo hace «insertar o actualizar una fila».
Una regla práctica:
INSERT INTO page_views (page_id, views)
VALUES (42, 1)
ON CONFLICT (page_id)
DO UPDATE SET views = page_views.views + 1;
Para un upsert concurrente y muy frecuente sobre una sola clave, ON CONFLICT es más simple y seguro. Recurre a MERGE cuando necesites lógica de «varias ramas + DELETE + sincronización», es decir, procesamiento por lotes y ETL.
Trampa. MERGE no te protege de las condiciones de carrera por sí solo. Si dos sesiones fusionan a la vez la misma clave aún inexistente, ambas toman la rama NOT MATCHED y una topa con un unique_violation. Para cargas de trabajo concurrentes, envuélvelo en un reintento o usa ON CONFLICT. Y recuerda: RETURNING desde MERGE solo está disponible a partir de PostgreSQL 17.
MERGEes una única sentencia que ejecutaINSERT,UPDATEoDELETEen una sola pasada, según exista o no una fila coincidente en la tabla de destino. PostgreSQL no lo tuvo hasta la versión 15, así que todo el mundo se apoyaba enINSERT ... ON CONFLICT. Ahora disponemos delMERGEreal, conforme al estándar SQL, y cubre casos queON CONFLICTsencillamente no puede: sincronizar dos tablas, eliminar las filas rezagadas y aplicar acciones distintas bajo condiciones distintas.Veamos cómo está montado y dónde cae realmente la línea entre
MERGEyON CONFLICT.Anatomía: destino, origen y ramas WHEN
MERGEtoma una tabla de destino (MERGE INTO), un origen (USING) y una condición de unión (ON). Después vienen la ramaWHEN MATCHED(la fila coincidió enON) y la ramaWHEN NOT MATCHED(presente en el origen, ausente en el destino).Tomemos un esquema con una tabla
usersy un lote entranteusers_staging.MERGE INTO users AS u USING users_staging AS s ON u.id = s.id WHEN MATCHED THEN UPDATE SET email = s.email, name = s.name, updated_at = now() WHEN NOT MATCHED THEN INSERT (id, email, name, created_at) VALUES (s.id, s.email, s.name, now());Aspectos clave que debes interiorizar:
VALUES.EXCLUDEDdentro de una rama: esa es sintaxis deON CONFLICTy no existe enMERGE. Simplemente escribes.email,s.name.MERGEno devuelve filas en versiones anteriores;RETURNINGsolo llegó en PostgreSQL 17.Ramas condicionales y DELETE
La verdadera potencia de
MERGEreside en las condicionesANDadicionales de cada rama, sumadas al hecho de que puedes tener varias ramas. Esto es exactamente lo queON CONFLICTno puede reproducir.MERGE INTO orders AS o USING incoming_orders AS i ON o.order_id = i.order_id WHEN MATCHED AND i.status = 'cancelled' THEN DELETE WHEN MATCHED AND o.amount <> i.amount THEN UPDATE SET amount = i.amount, status = i.status, updated_at = now() WHEN NOT MATCHED THEN INSERT (order_id, customer_id, amount, status) VALUES (i.order_id, i.customer_id, i.amount, i.status);Tres resultados distintos en una sola sentencia: un pedido cancelado se elimina, uno modificado se actualiza (solo cuando el importe realmente difiere) y uno nuevo se inserta. También puedes añadir
WHEN NOT MATCHED ... AND ...para filtrar qué inserciones se producen.Movimientos útiles:
WHEN MATCHED AND ... THEN DO NOTHING: omitir una fila de forma explícita.WHEN NOT MATCHED BY SOURCE THEN DELETE: eliminar del destino lo que está ausente en el origen (PostgreSQL 17; perfecto para una sincronización completa).Sincronización completa de dos tablas
Una tarea clásica: poner una tabla
employeesen línea con una exportación de un sistema de RR. HH.,hr_feed: dar de alta a los recién llegados, actualizar los cambios y dar de baja a los ausentes.MERGE INTO employees AS e USING hr_feed AS f ON e.emp_id = f.emp_id WHEN MATCHED AND (e.department, e.salary) IS DISTINCT FROM (f.department, f.salary) THEN UPDATE SET department = f.department, salary = f.salary WHEN NOT MATCHED THEN INSERT (emp_id, full_name, department, salary) VALUES (f.emp_id, f.full_name, f.department, f.salary) WHEN NOT MATCHED BY SOURCE THEN UPDATE SET status = 'terminated';IS DISTINCT FROMprotege contraUPDATEs inútiles cuando nada ha cambiado (y comparaNULLcorrectamente).NOT MATCHED BY SOURCEcubre el tercer ángulo: las filas que existen solo en el destino.MySQL no tiene un
MERGEindependiente; se usaINSERT ... ON DUPLICATE KEY UPDATE. ClickHouse toma un camino completamente distinto: inserción más la lógica deReplacingMergeTree/motor, con la deduplicación diferida a una fusión en segundo plano.MERGE frente a ON CONFLICT: cuál elegir
Ambos pueden hacer upsert, pero lo resuelven de forma diferente.
ON CONFLICTse dispara ante una violación de un índice único o restricción. Sin índice adecuado, no hay upsert.MERGEcoincide según una condiciónONarbitraria. No se requiere un índice (aunque ayuda al rendimiento).ON CONFLICTes atómico frente a inserciones concurrentes: bajo una condición de carrera, captura el conflicto de forma fiable.MERGEbajo alta concurrencia puede fallar con una violación de unicidad: no hace ninguna magia de skip-locked.MERGEpuede hacerDELETEy ejecutar varias ramas distintas;ON CONFLICTsolo hace «insertar o actualizar una fila».Una regla práctica:
-- Simple counter / idempotent insert - reach for ON CONFLICT INSERT INTO page_views (page_id, views) VALUES (42, 1) ON CONFLICT (page_id) DO UPDATE SET views = page_views.views + 1;Para un upsert concurrente y muy frecuente sobre una sola clave,
ON CONFLICTes más simple y seguro. Recurre aMERGEcuando necesites lógica de «varias ramas + DELETE + sincronización», es decir, procesamiento por lotes y ETL.Trampa.
MERGEno te protege de las condiciones de carrera por sí solo. Si dos sesiones fusionan a la vez la misma clave aún inexistente, ambas toman la ramaNOT MATCHEDy una topa con ununique_violation. Para cargas de trabajo concurrentes, envuélvelo en un reintento o usaON CONFLICT. Y recuerda:RETURNINGdesdeMERGEsolo está disponible a partir de PostgreSQL 17.