sqlpostgresqlmergeupsert

MERGE en PostgreSQL 15+: MATCHED / NOT MATCHED, upserts y sincronización de tablas

Una mirada práctica a MERGE en PostgreSQL 15+: las ramas MATCHED y NOT MATCHED, los patrones de upsert y sincronización, y cuándo usarlo en lugar de ON CONFLICT.

3 min de lecturaReferencesql · postgresql · merge · upsert · etl

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:

-- 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 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.

Practica con ejercicios reales

Resuelve ejercicios en el entrenador de SQL con corrección instantánea y pistas.

Abrir el entrenador