Hay una operación que los sistemas reales necesitan constantemente y que ninguna de las tres instrucciones anteriores resuelve: "inserta esta fila si no existe, y actualízala si ya existe".

Sincronizar el catálogo con el fichero que envía un proveedor cada semana: unos artículos ya están dados de alta y solo cambian de precio, otros son nuevos. Registrar el stock recibido de una referencia que quizá aún no existe. Guardar la puntuación de una reseña que el cliente puede haber escrito hace meses. Dar de alta un cliente que tal vez ya se registró. En los cuatro casos, la respuesta a "¿INSERT o UPDATE?" es depende de lo que haya en la tabla, y no lo sabes hasta que miras.

La solución que todo el mundo escribe la primera vez —mirar y luego decidir— es incorrecta, y lo es de una forma que no se manifiesta en desarrollo y sí en producción. Esta lección empieza explicando por qué, y sigue con las dos herramientas que PostgreSQL ofrece para resolverlo en una sola sentencia atómica: INSERT ... ON CONFLICT, su solución propia desde la versión 9.5, y MERGE, la del estándar SQL, disponible desde PostgreSQL 15.

Contenido

  1. El problema: insertar o actualizar según lo que haya
  2. Por qué la solución ingenua es incorrecta
  3. INSERT ... ON CONFLICT: la sintaxis
  4. DO NOTHING y DO UPDATE SET
  5. El destino del conflicto y por qué exige una restricción única
  6. La pseudotabla EXCLUDED y el patrón de acumulación
  7. WHERE en el DO UPDATE: actualizar solo si algo cambia
  8. RETURNING con upsert, y cómo saber si fue alta o modificación
  9. MERGE: el upsert del estándar SQL
  10. ON CONFLICT frente a MERGE
  11. Soporte por motor
  12. Tres casos de TiendaVerde
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. El problema: insertar o actualizar según lo que haya

Huerta del Turia (proveedor 1) manda cada lunes un fichero con su catálogo actualizado. Esta semana trae cinco referencias:

nombre precio coste unidades enviadas
Aceite de oliva virgen extra 500 ml 12.90 7.95 60
Arroz integral ecológico 1 kg 3.90 2.10 100
Tomate triturado ecológico 400 g 2.10 0.95 200
Kombucha de jengibre 750 ml 5.25 2.45 40
Garbanzos ecológicos 500 g 2.60 1.15 140

Cuatro ya están en el catálogo (con otros precios); el quinto es nuevo. Y ni siquiera todos los que están cambian: el arroz sigue a 3,90 € y 2,10 €.

Con lo que sabes hasta ahora, tendrías que separar el fichero a mano en dos grupos y escribir un INSERT para uno y un UPDATE para el otro. Es tedioso, y con quinientas referencias es directamente inviable. Lo que necesitas es una sentencia que decida por sí sola, fila a fila.

Eso es un UPSERT: update + insert.

  1. Por qué la solución ingenua es incorrecta

La primera idea de cualquiera, y la que escriben la mayoría de las aplicaciones:

-- ⚠️ INCORRECTA en cuanto hay más de un usuario
SELECT COUNT(*) FROM productos WHERE nombre = 'Garbanzos ecológicos 500 g';
-- si devuelve 0 → INSERT
-- si devuelve 1 → UPDATE

Funciona perfectamente… mientras seas la única persona conectada. En cuanto hay dos procesos trabajando a la vez, aparece una condición de carrera:

sequenceDiagram
    participant A as Sesión A
    participant DB as Base de datos
    participant B as Sesión B
    A->>DB: SELECT ... WHERE nombre = 'Garbanzos'
    DB-->>A: 0 filas → "no existe, insertaré"
    B->>DB: SELECT ... WHERE nombre = 'Garbanzos'
    DB-->>B: 0 filas → "no existe, insertaré"
    A->>DB: INSERT ... 'Garbanzos'
    DB-->>A: INSERT 0 1 ✅
    B->>DB: INSERT ... 'Garbanzos'
    DB-->>B: 💥 duplicate key value violates<br/>unique constraint

Las dos sesiones miran, las dos ven que no existe, las dos deciden insertar. La primera lo consigue; la segunda revienta. Y hay una variante peor: si no hubiera restricción única, la segunda inserción tendría éxito y acabarías con dos filas duplicadas sin ningún error.

El intervalo entre el SELECT y el INSERT puede parecer minúsculo —milisegundos—, pero en un sistema que procesa miles de operaciones por minuto esa ventana se cruza constantemente. No es un caso raro: es un caso garantizado. La regla general que hay detrás:

Comprobar y luego actuar en dos sentencias separadas nunca es seguro frente a la concurrencia. Entre la comprobación y la acción, el mundo ha podido cambiar.

Las tres formas de resolverlo, de peor a mejor:

Enfoque Problema
SELECT y luego INSERT/UPDATE Condición de carrera. Incorrecto
Intentar el INSERT y capturar el error de duplicado en la aplicación Funciona, pero convierte un caso normal en una excepción, ensucia el código y en algunos motores invalida la transacción entera
Una sola sentencia atómica: ON CONFLICT o MERGE El motor resuelve el conflicto internamente, con los bloqueos adecuados. Correcto

Por qué la tercera opción es segura. Cuando PostgreSQL ejecuta un INSERT ... ON CONFLICT, la comprobación y la escritura ocurren dentro de la misma operación, con el bloqueo de fila e índice adecuado: no hay ventana entre "mirar" y "actuar". El mecanismo completo —qué se bloquea, durante cuánto tiempo y qué ven las demás sesiones— es el módulo 9. Aquí basta con saber que una sentencia es atómica y dos no lo son.

  1. INSERT ... ON CONFLICT: la sintaxis

INSERT INTO tabla (columnas)
VALUES (...)
ON CONFLICT (columna_o_columnas)       -- o: ON CONSTRAINT nombre_restriccion
DO NOTHING;

-- o bien:

INSERT INTO tabla (columnas)
VALUES (...)
ON CONFLICT (columna_o_columnas)
DO UPDATE SET columna = valor [, ...]
[WHERE condición];

Se lee literalmente: "inserta esto; si choca con la unicidad de estas columnas, no hagas nada / o haz este UPDATE en su lugar".

Empecemos por el caso que no necesita nada nuevo, porque TiendaVerde ya tiene la restricción: clientes.email es UNIQUE.

  1. DO NOTHING y DO UPDATE SET

(Los tres ejemplos de este apartado se ejecutan en orden sobre la base recién recargada. Fíjate en los id: el motivo de que salgan los que salen se explica al final.)

DO NOTHING: ignora el conflicto

INSERT INTO clientes (nombre, apellidos, email, ciudad, pais, fecha_registro)
VALUES ('Lucía', 'Martínez Soler', '[email protected]', 'Alicante', 'España', DATE '2026-03-01')
ON CONFLICT (email) DO NOTHING;
INSERT 0 0

Cero filas insertadas, cero errores. El cliente ya existía con ese email, así que la sentencia se ha limitado a no hacer nada.

SELECT id, nombre, apellidos, email, ciudad, fecha_registro FROM clientes WHERE id = 1;
id nombre apellidos email ciudad fecha_registro
1 Lucía Martínez Soler [email protected] Valencia 2025-01-10

Intacto: sigue en Valencia y con su fecha de registro original.

DO NOTHING es ideal para datos maestros reejecutables: semillas de desarrollo, catálogos de referencia, cargas iniciales. Convierte un script frágil en uno idempotente (05-03).

DO UPDATE SET: el upsert de verdad

INSERT INTO clientes (nombre, apellidos, email, ciudad, pais, fecha_registro)
VALUES ('Lucía', 'Martínez Soler', '[email protected]', 'Alicante', 'España', DATE '2026-03-01')
ON CONFLICT (email) DO UPDATE
SET ciudad = EXCLUDED.ciudad
RETURNING id, nombre, apellidos, email, ciudad, fecha_registro;
id nombre apellidos email ciudad fecha_registro
1 Lucía Martínez Soler [email protected] Alicante 2025-01-10
INSERT 0 1

La ciudad ha pasado de Valencia a Alicante, y fecha_registro no ha cambiado: solo se actualiza lo que aparece en el SET. Es exactamente lo que queremos — la fecha de alta original de un cliente no debe reescribirse porque nos manden sus datos otra vez.

Y con un email nuevo, la misma sentencia inserta:

INSERT INTO clientes (nombre, apellidos, email, ciudad, pais, fecha_registro)
VALUES ('Aitor', 'Zubizarreta Egaña', '[email protected]', 'Bilbao', 'España', DATE '2026-03-01')
ON CONFLICT (email) DO UPDATE
SET ciudad = EXCLUDED.ciudad
RETURNING id, nombre, apellidos, email, ciudad, fecha_registro;
id nombre apellidos email ciudad fecha_registro
18 Aitor Zubizarreta Egaña [email protected] Bilbao 2026-03-01
INSERT 0 1

La misma sentencia ha insertado en un caso y actualizado en el otro, sin que tú hayas tenido que decidir nada. Eso es el upsert.

Por qué el id es 18 y no 16. TiendaVerde tiene 15 clientes y su secuencia quedó en 15 tras el setval del script. Pero un upsert que acaba en conflicto también consume un valor de la secuencia: PostgreSQL construye la fila completa —evaluando todos los DEFAULT, incluido nextvalantes de comprobar el índice único. Los dos ejemplos anteriores sobre Lucía se llevaron el 16 y el 17, y Aitor ha recibido el 18.

Es coherente con lo que viste en 05-02: las secuencias no se deshacen. Consecuencia práctica: en una tabla con upserts frecuentes, los id tienen huecos grandes y no cuentan filas. Si eso te importa, revisa el tipo: un INTEGER se agota en 2 147 483 647, y con upserts masivos eso llega antes de lo que parece. BIGINT es la respuesta habitual.

  1. El destino del conflicto y por qué exige una restricción única

El ON CONFLICT (...) no es decorativo: le dice a PostgreSQL qué conflicto debe interceptar. Y solo puede interceptar violaciones de una restricción UNIQUE, PRIMARY KEY o de un índice único.

Hay dos formas de indicarlo:

ON CONFLICT (email)                          -- por columna (inferencia)
ON CONFLICT ON CONSTRAINT clientes_email_key -- por nombre de restricción
Forma Ventaja Inconveniente
Por columna No depende del nombre de la restricción; sobrevive a un RENAME CONSTRAINT Ambigua si hay dos restricciones sobre las mismas columnas
Por nombre Totalmente explícita Se rompe si alguien renombra la restricción (otro argumento a favor de nombrarlas bien, 05-01)

Si indicas columnas que no están cubiertas por ninguna restricción única, falla:

-- ⚠️ INCORRECTA: productos.nombre no es UNIQUE en TiendaVerde
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock)
VALUES ('Aceite de oliva virgen extra 500 ml', 1, 1, 12.90, 7.95, 60)
ON CONFLICT (nombre) DO UPDATE SET precio = EXCLUDED.precio;
ERROR:  there is no unique or exclusion constraint matching the ON CONFLICT specification

Y esto es una limitación de diseño, no un capricho. Para que PostgreSQL pueda decidir atómicamente "ya existe", necesita un índice único que se lo diga sin recorrer la tabla; sin él tendría que buscar, y volveríamos a la ventana de carrera del apartado 2.

Así que para sincronizar el catálogo por nombre hay que añadir la restricción:

-- Ejemplo puntual de esta lección: NO forma parte del esquema
-- canónico de TiendaVerde. Añádela para practicar y quítala después.
ALTER TABLE productos
ADD CONSTRAINT uq_productos_nombre UNIQUE (nombre);
ALTER TABLE

Funciona porque los veinte nombres del catálogo son distintos. Y de paso ilustra algo que verás a fondo en 05-06: añadir un UNIQUE crea un índice por debajo, y ese índice es lo que hace posible el upsert (las estructuras y su coste, en el módulo 8).

Ahora sí:

INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock)
VALUES ('Aceite de oliva virgen extra 500 ml', 1, 1, 12.90, 7.95, 60)
ON CONFLICT (nombre) DO UPDATE
SET precio = EXCLUDED.precio,
    coste  = EXCLUDED.coste
RETURNING id, nombre, precio, coste, stock;
id nombre precio coste stock
1 Aceite de oliva virgen extra 500 ml 12.90 7.95 120
INSERT 0 1

Precio y coste actualizados; el stock sigue en 120 porque no está en el SET. Las 60 unidades del fichero se han ignorado, que en este caso es lo correcto: son unidades enviadas, no el stock total.

  1. La pseudotabla EXCLUDED y el patrón de acumulación

EXCLUDED es la clave de todo el mecanismo. Es una pseudotabla que contiene la fila que se intentó insertar y fue rechazada por el conflicto.

Dentro del DO UPDATE SET conviven dos mundos:

Referencia A qué apunta
EXCLUDED.columna El valor propuesto (el que venía en el VALUES o en el SELECT)
productos.columna o tabla.columna El valor actual de la fila que ya está en la tabla

Con las dos puedes escribir cualquier regla de fusión:

-- Quedarse con el valor nuevo
SET precio = EXCLUDED.precio

-- Conservar el actual (equivale a no ponerlo)
SET precio = productos.precio

-- Quedarse con el mayor de los dos
SET stock = GREATEST(productos.stock, EXCLUDED.stock)

-- ACUMULAR: sumar el nuevo al existente
SET stock = productos.stock + EXCLUDED.stock

Ese último es el patrón de acumulación, y es probablemente el uso más valioso del upsert. Registrar la recepción de mercancía es exactamente eso: "si el producto ya está dado de alta, súmale las unidades; si no, créalo con esas unidades".

-- Estado inicial
SELECT id, nombre, stock FROM productos WHERE id IN (1, 2, 5, 16) ORDER BY id;
id nombre stock
1 Aceite de oliva virgen extra 500 ml 120
2 Arroz integral ecológico 1 kg 200
5 Tomate triturado ecológico 400 g 300
16 Kombucha de jengibre 750 ml 60
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock) VALUES
('Aceite de oliva virgen extra 500 ml', 1, 1, 12.90, 7.95,  60),
('Arroz integral ecológico 1 kg',       1, 1,  3.90, 2.10, 100),
('Tomate triturado ecológico 400 g',    1, 1,  2.10, 0.95, 200),
('Kombucha de jengibre 750 ml',         4, 1,  5.25, 2.45,  40),
('Garbanzos ecológicos 500 g',          1, 1,  2.60, 1.15, 140)
ON CONFLICT (nombre) DO UPDATE
SET stock = productos.stock + EXCLUDED.stock
RETURNING id, nombre, precio, stock;
id nombre precio stock
1 Aceite de oliva virgen extra 500 ml 12.50 180
2 Arroz integral ecológico 1 kg 3.90 300
5 Tomate triturado ecológico 400 g 1.95 500
16 Kombucha de jengibre 750 ml 4.95 100
21 Garbanzos ecológicos 500 g 2.60 140
INSERT 0 5

Cinco filas: cuatro acumuladas y una creada. 120+60=180, 200+100=300, 300+200=500, 60+40=100, y los garbanzos entran con sus 140 unidades y el id 21.

Fíjate en un detalle revelador: los precios de las cuatro filas actualizadas siguen siendo los antiguos (12,50 €, 3,90 €, 1,95 €, 4,95 €), porque el SET solo toca stock. Solo la fila nueva ha estrenado el precio del fichero. El upsert te deja controlar campo a campo qué se fusiona y qué se conserva.

Y el aviso obligatorio, hermano del de UPDATE ... FROM (05-03):

⚠️ EXCLUDED no acumula entre tuplas de la misma sentencia. Si el fichero trajera dos veces el mismo nombre, PostgreSQL fallaría con ON CONFLICT DO UPDATE command cannot affect row a second time. No suma las dos: se niega. Deduplica el origen antes de hacer upsert.

ERROR:  ON CONFLICT DO UPDATE command cannot affect row a second time
HINT:  Ensure that no rows proposed for insertion within the same command have duplicate constrained values.

Ese error es en realidad una buena noticia: el motor se niega a hacer algo ambiguo en lugar de inventarse un resultado. Compáralo con UPDATE ... FROM, que en la misma situación elige una coincidencia al azar sin avisar.

  1. WHERE en el DO UPDATE: actualizar solo si algo cambia

El DO UPDATE admite su propio WHERE, que decide si la actualización se aplica o se descarta:

INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock) VALUES
('Aceite de oliva virgen extra 500 ml', 1, 1, 12.90, 7.95,  60),
('Arroz integral ecológico 1 kg',       1, 1,  3.90, 2.10, 100),
('Tomate triturado ecológico 400 g',    1, 1,  2.10, 0.95, 200),
('Kombucha de jengibre 750 ml',         4, 1,  5.25, 2.45,  40),
('Garbanzos ecológicos 500 g',          1, 1,  2.60, 1.15, 140)
ON CONFLICT (nombre) DO UPDATE
SET precio = EXCLUDED.precio,
    coste  = EXCLUDED.coste
WHERE productos.precio IS DISTINCT FROM EXCLUDED.precio
   OR productos.coste  IS DISTINCT FROM EXCLUDED.coste
RETURNING id, nombre, precio, coste;
id nombre precio coste
1 Aceite de oliva virgen extra 500 ml 12.90 7.95
5 Tomate triturado ecológico 400 g 2.10 0.95
16 Kombucha de jengibre 750 ml 5.25 2.45
21 Garbanzos ecológicos 500 g 2.60 1.15
INSERT 0 4

Cuatro filas en lugar de cinco. El arroz integral venía con 3,90 € y 2,10 €, exactamente lo que ya tenía, así que el WHERE ha descartado su actualización y ni siquiera aparece en el RETURNING.

Por qué merece la pena esa línea de más:

Motivo Detalle
Menos escrituras Cada UPDATE crea una versión nueva de la fila aunque el valor sea idéntico. Con miles de filas y un 5 % de cambios reales, el ahorro es enorme
Menos bloqueos Una fila no actualizada no se bloquea para las demás sesiones (módulo 9)
Auditoría honesta Si hay triggers de auditoría (módulo 10), no registran cambios que no han ocurrido
El RETURNING dice la verdad Te devuelve solo lo que realmente ha cambiado

Fíjate en el uso de IS DISTINCT FROM en lugar de <>. Es directamente 04-03: si productos.coste fuera NULL, la comparación productos.coste <> EXCLUDED.coste daría UNKNOWN, el WHERE la descartaría y el coste nunca se actualizaría. IS DISTINCT FROM trata dos nulos como iguales y un nulo frente a un valor como distintos, que es lo que aquí necesitamos. Es uno de los sitios donde aquella lección se cobra la deuda.

  1. RETURNING con upsert, y cómo saber si fue alta o modificación

RETURNING funciona igual que en INSERT y UPDATE: devuelve las filas afectadas, ya sean insertadas o actualizadas. Lo que no te dice de forma directa es cuál fue cuál.

Existe un truco muy extendido basado en la columna de sistema xmax (ejemplo sobre la base recién recargada):

INSERT INTO clientes (nombre, apellidos, email, ciudad, pais, fecha_registro) VALUES
('Lucía',   'Martínez Soler',    '[email protected]', 'Alicante', 'España', DATE '2026-03-01'),
('Marisol', 'Aguirre Peña',      '[email protected]','Málaga',   'España', DATE '2026-03-01')
ON CONFLICT (email) DO UPDATE
SET ciudad = EXCLUDED.ciudad
RETURNING id,
          nombre,
          email,
          ciudad,
          (xmax = 0) AS fue_insercion;
id nombre email ciudad fue_insercion
1 Lucía [email protected] Alicante false
17 Marisol [email protected] Málaga true
INSERT 0 2

Lucía se ha actualizado (false), Marisol se ha creado (true) — con el id 17, porque el intento fallido sobre Lucía se llevó el 16.

Cómo funciona. xmax es una columna oculta que PostgreSQL usa internamente para el control de versiones: guarda el identificador de la transacción que borró o bloqueó la fila. En una fila recién insertada vale 0; en una actualizada dentro del upsert, no.

⚠️ Úsalo con reservas. xmax es un detalle de implementación, no una interfaz documentada. Puede cambiar entre versiones, y hay situaciones (filas bloqueadas por otras transacciones, SELECT ... FOR UPDATE previos) en las que un xmax distinto de cero no significa lo que crees. Sirve perfectamente para depurar y para un informe interno; no construyas lógica de negocio crítica sobre él.

Y una limitación que sí importa: con DO NOTHING, las filas en conflicto no aparecen en el RETURNING en absoluto. Solo verás las que realmente se insertaron:

INSERT INTO categorias (nombre, descripcion) VALUES
('Bebidas',           'Ya existe'),
('Despensa a granel', 'Legumbres, cereales y frutos secos sin envase')
ON CONFLICT (nombre) DO NOTHING
RETURNING id, nombre;
id nombre
7 Despensa a granel
INSERT 0 1

Una de las dos. Si necesitas saber también qué se ignoró, DO NOTHING no te lo va a decir.

  1. MERGE: el upsert del estándar SQL

ON CONFLICT es una extensión de PostgreSQL. El estándar SQL:2003 define para lo mismo la instrucción MERGE, que PostgreSQL incorporó en la versión 15.

MERGE INTO tabla_destino AS d
USING origen AS o
   ON d.clave = o.clave
WHEN MATCHED [AND condición] THEN
    UPDATE SET columna = valor [, ...]
WHEN MATCHED [AND condición] THEN
    DELETE
WHEN NOT MATCHED [AND condición] THEN
    INSERT (columnas) VALUES (valores)
[WHEN NOT MATCHED THEN DO NOTHING];

La lógica es la de un JOIN: se emparejan destino y origen por la condición del ON, y para cada fila se ejecuta la primera cláusula WHEN que se cumpla.

El origen puede ser una tabla, una consulta o una lista de valores. Para el caso de TiendaVerde, lo natural es una tabla de staging con el contenido del fichero del proveedor:

-- Tabla auxiliar de esta lección: NO forma parte del esquema canónico
CREATE TEMP TABLE catalogo_proveedor (
    nombre       VARCHAR(150)  NOT NULL PRIMARY KEY,
    categoria_id INTEGER       NOT NULL,
    precio       NUMERIC(10,2) NOT NULL CHECK (precio >= 0),
    coste        NUMERIC(10,2) NOT NULL CHECK (coste  >= 0),
    unidades     INTEGER       NOT NULL CHECK (unidades > 0)
);

INSERT INTO catalogo_proveedor (nombre, categoria_id, precio, coste, unidades) VALUES
('Aceite de oliva virgen extra 500 ml', 1, 12.90, 7.95,  60),
('Arroz integral ecológico 1 kg',       1,  3.90, 2.10, 100),
('Tomate triturado ecológico 400 g',    1,  2.10, 0.95, 200),
('Kombucha de jengibre 750 ml',         4,  5.25, 2.45,  40),
('Garbanzos ecológicos 500 g',          1,  2.60, 1.15, 140);
CREATE TABLE
INSERT 0 5

Ese COPY a una tabla de staging seguido de una fusión hacia la tabla definitiva es el patrón profesional de integración de datos que anunciaba 05-02.

Y ahora el mismo caso del apartado 7, resuelto con MERGE:

MERGE INTO productos AS p
USING catalogo_proveedor AS c
   ON p.nombre = c.nombre
WHEN MATCHED AND (p.precio IS DISTINCT FROM c.precio
               OR p.coste  IS DISTINCT FROM c.coste) THEN
    UPDATE SET precio = c.precio,
               coste  = c.coste
WHEN NOT MATCHED THEN
    INSERT (nombre, categoria_id, proveedor_id, precio, coste, stock)
    VALUES (c.nombre, c.categoria_id, 1, c.precio, c.coste, c.unidades);
MERGE 4

Cuatro filas afectadas: tres actualizaciones (aceite, tomate y kombucha) y una inserción (garbanzos). El arroz no cumple la condición del WHEN MATCHED y, al no haber más cláusulas aplicables, se queda como está.

SELECT id, nombre, precio, coste, stock
FROM   productos
WHERE  proveedor_id = 1
ORDER BY id;
id nombre precio coste stock
1 Aceite de oliva virgen extra 500 ml 12.90 7.95 120
2 Arroz integral ecológico 1 kg 3.90 2.10 200
5 Tomate triturado ecológico 400 g 2.10 0.95 300
16 Kombucha de jengibre 750 ml 5.25 2.45 60
17 Zumo de naranja prensado en frío 1 L 5.40 2.60 90
21 Garbanzos ecológicos 500 g 2.60 1.15 140

Fíjate en el zumo de naranja (17): es de Huerta del Turia pero no venía en el fichero, y ha quedado intacto. Ni ON CONFLICT ni este MERGE hacen nada con las filas del destino que el origen no menciona. Si el criterio de negocio fuera "lo que no está en el fichero se descataloga", harían falta dos operaciones… o una cláusula WHEN NOT MATCHED BY SOURCE, que PostgreSQL 16 todavía no tiene (llegó en la 17).

Lo que MERGE sabe hacer y ON CONFLICT no

Varias condiciones y DELETE. Este MERGE sincroniza el catálogo y además descataloga lo que el proveedor manda a precio 0:

MERGE INTO productos AS p
USING catalogo_proveedor AS c
   ON p.nombre = c.nombre
WHEN MATCHED AND c.precio = 0 THEN
    UPDATE SET activo = FALSE, stock = 0
WHEN MATCHED AND (p.precio IS DISTINCT FROM c.precio) THEN
    UPDATE SET precio = c.precio, coste = c.coste
WHEN NOT MATCHED AND c.precio > 0 THEN
    INSERT (nombre, categoria_id, proveedor_id, precio, coste, stock)
    VALUES (c.nombre, c.categoria_id, 1, c.precio, c.coste, c.unidades);

Tres reglas distintas en una sentencia, evaluadas en orden: la primera que se cumpla gana. Con ON CONFLICT esto no se puede expresar; harían falta varias sentencias.

Limitaciones de MERGE en PostgreSQL 16

Limitación Detalle
Sin RETURNING MERGE ... RETURNING llegó en PostgreSQL 17. En la 16 solo tienes el contador MERGE N
Sin WHEN NOT MATCHED BY SOURCE También de la 17
No es inmune a la concurrencia Este es el importante: bajo carga concurrente, un MERGE puede fallar con duplicate key value si otra sesión inserta la misma clave entre el emparejamiento y la escritura. ON CONFLICT garantiza que eso no ocurra
No agrupa el origen Si catalogo_proveedor trajera dos filas con el mismo nombre, el MERGE falla con MERGE command cannot affect row a second time, igual que el upsert

Esa tercera limitación es contraintuitiva y conviene retenerla: la instrucción estándar es menos robusta frente a la concurrencia que la extensión propietaria, porque ON CONFLICT se apoya directamente en el índice único y MERGE no lo exige.

  1. ON CONFLICT frente a MERGE

INSERT ... ON CONFLICT MERGE
Estándar SQL No: extensión de PostgreSQL (SQL:2003)
Disponible desde PostgreSQL 9.5 PostgreSQL 15
Exige restricción única , obligatoriamente No: basta la condición del ON
Atómico frente a concurrencia Sí, garantizado No siempre: puede fallar con clave duplicada
Acciones posibles DO NOTHING, DO UPDATE UPDATE, INSERT, DELETE, DO NOTHING
Varias condiciones Una sola (DO UPDATE ... WHERE) Varias cláusulas WHEN, evaluadas en orden
Origen VALUES o SELECT Tabla, consulta o VALUES
RETURNING No en PostgreSQL 16 (sí en la 17)
Acceso a la fila existente y a la propuesta tabla.col y EXCLUDED.col destino.col y origen.col
Legibilidad con reglas complejas Se complica Mejor

Cuándo usar cada uno:

  • ON CONFLICT cuando el caso sea el clásico "insertar o actualizar" sobre una clave única, cuando haya concurrencia real, o cuando necesites RETURNING. Es el 90 % de los casos.
  • MERGE cuando necesites varias reglas (actualizar unos, borrar otros, insertar los demás), cuando el emparejamiento no sea por una clave única, cuando el origen sea una consulta compleja, o cuando el código deba ser portable a Oracle o SQL Server.

  1. Soporte por motor

Motor Sintaxis Notas
PostgreSQL 15+ INSERT ... ON CONFLICT y MERGE Las dos. ON CONFLICT es la recomendada para el caso simple
MySQL / MariaDB INSERT ... ON DUPLICATE KEY UPDATE No se indica la restricción: se dispara con cualquier clave única violada. Para leer la fila propuesta: AS new (MySQL 8.0.19+) o la antigua función VALUES(col). MySQL 8 no tiene MERGE
SQLite 3.24+ INSERT ... ON CONFLICT Copiado de PostgreSQL, con excluded en minúscula. Sin MERGE
SQL Server MERGE Desde 2008. Históricamente con varios errores de concurrencia documentados; muchos equipos prefieren UPDATE + INSERT en transacción
Oracle MERGE Desde 9i, muy maduro y de uso masivo. No tiene ON CONFLICT

El caso especial de INSERT OR REPLACE de SQLite

SQLite ofrece además INSERT OR REPLACE INTO ..., que mucha gente usa creyendo que es un upsert. No lo es, y la diferencia es grave:

ON CONFLICT DO UPDATE INSERT OR REPLACE
Qué hace con la fila existente La modifica La borra y crea una nueva
Columnas no mencionadas Conservan su valor Se pierden: toman su DEFAULT o NULL
Clave primaria (rowid) Se conserva Cambia
ON DELETE CASCADE de las hijas No se dispara Se dispara: las filas hijas se borran
Triggers de borrado No

Aplicado a TiendaVerde sería una catástrofe silenciosa: un INSERT OR REPLACE sobre productos borraría la fila, y con ella se irían en cascada todas sus reseñas (resenas.producto_id es ON DELETE CASCADE). El "upsert" habría destruido datos de otra tabla sin mencionarla.

La regla: en SQLite usa ON CONFLICT ... DO UPDATE, nunca INSERT OR REPLACE, salvo que el borrado-y-recreación sea exactamente lo que quieres.

  1. Tres casos de TiendaVerde

12.1. Sincronizar el catálogo con el fichero del proveedor

Resuelto en los apartados 7 y 9, de las dos formas. Requiere la restricción uq_productos_nombre para la versión con ON CONFLICT; el MERGE funcionaría igual sin ella.

12.2. Registrar o incrementar el stock recibido

Resuelto en el apartado 6 con el patrón de acumulación SET stock = productos.stock + EXCLUDED.stock. Es el caso donde el upsert brilla: una sola sentencia procesa un albarán entero, dé de alta referencias nuevas o sume a las existentes.

Una versión más completa, que además actualiza el precio de compra y anota la fecha de alta solo en las referencias nuevas:

INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock, fecha_alta)
SELECT c.nombre, c.categoria_id, 1, c.precio, c.coste, c.unidades, DATE '2026-03-02'
FROM   catalogo_proveedor AS c
ON CONFLICT (nombre) DO UPDATE
SET stock = productos.stock + EXCLUDED.stock,
    coste = EXCLUDED.coste
RETURNING id, nombre, precio, coste, stock, fecha_alta, (xmax = 0) AS alta_nueva;
id nombre precio coste stock fecha_alta alta_nueva
1 Aceite de oliva virgen extra 500 ml 12.50 7.95 180 2025-01-15 false
2 Arroz integral ecológico 1 kg 3.90 2.10 300 2025-01-15 false
5 Tomate triturado ecológico 400 g 1.95 0.95 500 2025-01-20 false
16 Kombucha de jengibre 750 ml 4.95 2.45 100 2025-02-20 false
21 Garbanzos ecológicos 500 g 2.60 1.15 140 2026-03-02 true
INSERT 0 5

Cuatro acumulaciones y un alta. Y observa la columna fecha_alta: las cuatro existentes conservan la suya (2025) y solo la nueva estrena la de hoy, porque fecha_alta no aparece en el SET. Esa asimetría —unos campos se fusionan y otros solo se rellenan al crear— es justo lo que hace útil el DO UPDATE frente a un REPLACE.

Fíjate también en que el origen es un SELECT, no un VALUES. INSERT ... SELECT ... ON CONFLICT combina lo de 05-02 con lo de esta lección, y es la forma habitual de procesar una tabla de staging entera.

12.3. Actualizar la puntuación de una reseña ya existente

Un cliente vuelve a valorar un producto que ya había reseñado. La regla de negocio: una reseña por cliente y producto, y la última sustituye a la anterior.

Hoy resenas no impide duplicados, así que primero hay que declarar esa regla:

-- Ejemplo puntual de esta lección: NO forma parte del esquema
-- canónico de TiendaVerde.
ALTER TABLE resenas
ADD CONSTRAINT uq_resenas_producto_cliente UNIQUE (producto_id, cliente_id);
ALTER TABLE

Funciona porque las doce reseñas actuales tienen pares (producto_id, cliente_id) distintos. Si hubiera duplicados, el ALTER TABLE fallaría — que es exactamente lo que debe hacer.

Ahora el upsert. El cliente 6 (Pau Llorens Vidal) había puesto un 2 a la Kombucha; le han cambiado la receta y quiere subirlo a 4:

SELECT id, producto_id, cliente_id, puntuacion, comentario, fecha
FROM   resenas WHERE producto_id = 16 AND cliente_id = 6;
id producto_id cliente_id puntuacion comentario fecha
6 16 6 2 Demasiado jengibre para mi gusto, casi no se puede beber. 2025-06-20
INSERT INTO resenas (producto_id, cliente_id, puntuacion, comentario, fecha)
VALUES (16, 6, 4, 'Han suavizado el jengibre y ahora está muy equilibrada.', DATE '2026-03-02')
ON CONFLICT (producto_id, cliente_id) DO UPDATE
SET puntuacion = EXCLUDED.puntuacion,
    comentario = EXCLUDED.comentario,
    fecha      = EXCLUDED.fecha
RETURNING id, producto_id, cliente_id, puntuacion, comentario, fecha, (xmax = 0) AS nueva;
id producto_id cliente_id puntuacion comentario fecha nueva
6 16 6 4 Han suavizado el jengibre y ahora está muy equilibrada. 2026-03-02 false
INSERT 0 1

Misma fila (id 6), contenido nuevo. Y con un cliente que nunca ha reseñado ese producto, la misma sentencia crea la reseña:

INSERT INTO resenas (producto_id, cliente_id, puntuacion, comentario, fecha)
VALUES (16, 1, 5, 'La mejor kombucha que he probado.', DATE '2026-03-02')
ON CONFLICT (producto_id, cliente_id) DO UPDATE
SET puntuacion = EXCLUDED.puntuacion,
    comentario = EXCLUDED.comentario,
    fecha      = EXCLUDED.fecha
RETURNING id, producto_id, cliente_id, puntuacion, fecha, (xmax = 0) AS nueva;
id producto_id cliente_id puntuacion fecha nueva
14 16 1 5 2026-03-02 true
INSERT 0 1

El id es 14 y no 13 por lo mismo del apartado 4: el upsert anterior, que acabó en DO UPDATE, ya se había llevado el 13 de la secuencia.

Y el efecto sobre la valoración del producto:

SELECT p.id,
       p.nombre,
       COUNT(r.id)                 AS resenas,
       ROUND(AVG(r.puntuacion), 2) AS media
FROM   productos AS p
LEFT JOIN resenas AS r ON r.producto_id = p.id
WHERE  p.id = 16
GROUP BY p.id, p.nombre;
id nombre resenas media
16 Kombucha de jengibre 750 ml 2 4.50

De un 2 solitario a una media de 4,50 con dos reseñas. La Kombucha deja de ser el peor producto del catálogo.

Recuerda deshacer los ejemplos. Las dos restricciones de esta lección (uq_productos_nombre y uq_resenas_producto_cliente) no forman parte del esquema del curso. Quítalas con ALTER TABLE ... DROP CONSTRAINT ... o, más simple, recarga tiendaverde.sql. La sintaxis completa de ALTER TABLE es la próxima lección.

Errores Comunes y Consejos

  • Resolver el upsert con SELECT + INSERT/UPDATE. Condición de carrera garantizada bajo concurrencia. Una sentencia es atómica; dos no.
  • Usar ON CONFLICT sobre columnas sin restricción única. there is no unique or exclusion constraint matching the ON CONFLICT specification. El índice único es el mecanismo, no un requisito burocrático.
  • Olvidar el prefijo EXCLUDED. en el DO UPDATE. SET precio = precio es una asignación circular: la fila se queda como estaba y no da error.
  • Traer el mismo valor de clave dos veces en la misma sentencia. cannot affect row a second time. Deduplica el origen antes.
  • Comparar con <> en lugar de IS DISTINCT FROM en el WHERE del DO UPDATE. Con un NULL de por medio la comparación da UNKNOWN y la fila nunca se actualiza (04-03).
  • Esperar que DO NOTHING te informe de lo ignorado. No aparece en el RETURNING. Si necesitas saberlo, usa DO UPDATE con WHERE.
  • Construir lógica de negocio sobre xmax = 0. Es un detalle de implementación. Vale para depurar; no para facturar.
  • Creer que el upsert actualiza también lo que el origen no menciona. No lo hace. Las filas del destino ausentes del fichero quedan intactas.
  • Usar INSERT OR REPLACE en SQLite creyendo que es un upsert. Borra y recrea: pierde columnas, cambia el rowid y dispara las cascadas de borrado.
  • Suponer que MERGE es seguro frente a la concurrencia. No lo es en PostgreSQL 16: puede fallar con clave duplicada. ON CONFLICT sí lo es.
  • Consejo: para un albarán o un fichero, carga a una tabla de staging y fusiona desde ahí. COPY + INSERT ... SELECT ... ON CONFLICT (o MERGE) es el patrón profesional de integración.
  • Consejo: decide campo a campo qué se fusiona. precio sí, fecha_alta no, stock acumulando. Esa granularidad es todo el valor del DO UPDATE.
  • Consejo: pon siempre el WHERE ... IS DISTINCT FROM en el DO UPDATE. Menos escrituras, menos bloqueos, auditoría honesta y un RETURNING que dice la verdad.

Ejercicios

Trabaja sobre la base recién recargada, dentro de BEGINROLLBACK.

Ejercicio 1

TiendaVerde recibe un fichero de altas y bajas de clientes procedente de una campaña de marketing:

nombre apellidos email ciudad pais
Lucía Martínez Soler [email protected] Gandía España
Sofia Moreira Costa [email protected] Coímbra Portugal
Aitor Zubizarreta Egaña [email protected] Bilbao España
Nadia Benali Torres [email protected] Valencia España

Escribe una sola sentencia que dé de alta a los que no existan y actualice la ciudad de los que sí, con estas reglas:

  1. fecha_registro debe ser 2026-03-02 para los nuevos y no debe modificarse en los existentes.
  2. La ciudad solo debe actualizarse si realmente cambia.
  3. El resultado debe indicar, por fila, si fue alta o modificación.

Después responde: ¿cuántas filas devuelve el RETURNING y por qué?

Ejercicio 2

Resuelve el mismo caso del ejercicio 1 con MERGE, cargando antes los datos en una tabla temporal clientes_campana. Después compara las dos soluciones respondiendo a estas preguntas:

  1. ¿Qué diferencia hay en la salida de psql?
  2. ¿Cuál de las dos es segura si dos procesos ejecutan la campaña a la vez?
  3. ¿Cuál escribirías si además hubiera que desactivar a los clientes que no aparecen en el fichero? ¿Se puede hacer en PostgreSQL 16?

Ejercicio 3

Un compañero quiere sincronizar el catálogo y escribe esto:

-- ⚠️ INCORRECTA
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock) VALUES
('Miel de azahar cruda 500 g',  1, 2, 10.20, 5.60,  40),
('Pasta de espelta 500 g',      1, 2,  2.95, 1.40,  80),
('Miel de azahar cruda 500 g',  1, 2, 10.50, 5.75,  25)
ON CONFLICT (id) DO UPDATE
SET precio = precio,
    stock  = stock + EXCLUDED.stock;

Tiene tres errores distintos. Encuéntralos, explica el síntoma de cada uno y reescribe la sentencia correctamente, indicando qué tendría que estar declarado en el esquema para que funcione y qué resultado daría.

Soluciones

Solución 1

BEGIN;

INSERT INTO clientes (nombre, apellidos, email, ciudad, pais, fecha_registro) VALUES
('Lucía', 'Martínez Soler',    '[email protected]', 'Gandía',  'España',   DATE '2026-03-02'),
('Sofia', 'Moreira Costa',     '[email protected]',   'Coímbra', 'Portugal', DATE '2026-03-02'),
('Aitor', 'Zubizarreta Egaña', '[email protected]',     'Bilbao',  'España',   DATE '2026-03-02'),
('Nadia', 'Benali Torres',     '[email protected]',   'Valencia','España',   DATE '2026-03-02')
ON CONFLICT (email) DO UPDATE
SET ciudad = EXCLUDED.ciudad
WHERE clientes.ciudad IS DISTINCT FROM EXCLUDED.ciudad
RETURNING id,
          nombre || ' ' || apellidos AS cliente,
          email,
          ciudad,
          fecha_registro,
          (xmax = 0) AS alta_nueva;
id cliente email ciudad fecha_registro alta_nueva
1 Lucía Martínez Soler [email protected] Gandía 2025-01-10 false
7 Sofia Moreira Costa [email protected] Coímbra 2025-03-21 false
18 Aitor Zubizarreta Egaña [email protected] Bilbao 2026-03-02 true
19 Nadia Benali Torres [email protected] Valencia 2026-03-02 true
INSERT 0 4
ROLLBACK;

Cuatro filas, y no es casualidad que salgan las cuatro:

Cliente Qué ocurre Por qué
Lucía (1) Actualización Existía en Valencia, pasa a Gandía: la ciudad cambia
Sofia (7) Actualización Existía en Lisboa, pasa a Coímbra: la ciudad cambia
Aitor Alta Email nuevo
Nadia Alta Email nuevo

Si el fichero hubiera traído a Lucía con su ciudad actual (Valencia), el WHERE ... IS DISTINCT FROM habría descartado esa actualización y el RETURNING habría devuelto tres filas.

Las tres decisiones del enunciado:

  1. fecha_registro fuera del SET. Aparece en el INSERT (para los nuevos) pero no en el DO UPDATE, así que los existentes conservan la suya: 2025-01-10 y 2025-03-21. Es el mismo principio que con fecha_alta en 12.2.
  2. WHERE clientes.ciudad IS DISTINCT FROM EXCLUDED.ciudad, no <>: si algún cliente tuviera la ciudad a NULL (la columna lo admite), <> daría UNKNOWN y jamás se actualizaría.
  3. (xmax = 0) AS alta_nueva, con la reserva del apartado 8: sirve para el informe de la campaña, no para lógica crítica.

Solución 2

BEGIN;

CREATE TEMP TABLE clientes_campana (
    email     VARCHAR(120) PRIMARY KEY,
    nombre    VARCHAR(60)  NOT NULL,
    apellidos VARCHAR(90)  NOT NULL,
    ciudad    VARCHAR(80),
    pais      VARCHAR(60)  NOT NULL
);

INSERT INTO clientes_campana (email, nombre, apellidos, ciudad, pais) VALUES
('[email protected]', 'Lucía', 'Martínez Soler',    'Gandía',   'España'),
('[email protected]',   'Sofia', 'Moreira Costa',     'Coímbra',  'Portugal'),
('[email protected]',     'Aitor', 'Zubizarreta Egaña', 'Bilbao',   'España'),
('[email protected]',   'Nadia', 'Benali Torres',     'Valencia', 'España');

MERGE INTO clientes AS c
USING clientes_campana AS k
   ON c.email = k.email
WHEN MATCHED AND c.ciudad IS DISTINCT FROM k.ciudad THEN
    UPDATE SET ciudad = k.ciudad
WHEN NOT MATCHED THEN
    INSERT (nombre, apellidos, email, ciudad, pais, fecha_registro)
    VALUES (k.nombre, k.apellidos, k.email, k.ciudad, k.pais, DATE '2026-03-02');
CREATE TABLE
INSERT 0 4
MERGE 4
SELECT id, nombre || ' ' || apellidos AS cliente, email, ciudad, fecha_registro
FROM   clientes
WHERE  email IN (SELECT email FROM clientes_campana)
ORDER BY id;
id cliente email ciudad fecha_registro
1 Lucía Martínez Soler [email protected] Gandía 2025-01-10
7 Sofia Moreira Costa [email protected] Coímbra 2025-03-21
16 Aitor Zubizarreta Egaña [email protected] Bilbao 2026-03-02
17 Nadia Benali Torres [email protected] Valencia 2026-03-02
ROLLBACK;

Resultado idéntico en el contenido, con un detalle revelador en los id: aquí son 16 y 17, no 18 y 19 como con ON CONFLICT. El motivo es que MERGE solo evalúa los DEFAULT de las filas que realmente inserta, mientras que el upsert los evalúa también en las que acaban en conflicto. MERGE desperdicia menos valores de secuencia. (Con todo, no cuentes con id consecutivos en ninguno de los dos: las secuencias tienen huecos por diseño.)

Las tres respuestas:

1. La salida. MERGE 4 frente a INSERT 0 4, y sobre todo: el MERGE de PostgreSQL 16 no admite RETURNING, así que hay que consultar después para ver qué ha pasado — y esa consulta ya no puede distinguir altas de modificaciones. Es la pérdida más notable al cambiar de instrucción.

2. Concurrencia. La segura es ON CONFLICT. Si dos procesos lanzan la campaña a la vez, el MERGE puede fallar con duplicate key value violates unique constraint "clientes_email_key", porque su emparejamiento no se apoya en el índice único de la misma forma. ON CONFLICT está diseñado precisamente para ese escenario.

3. Desactivar a los ausentes. Sería el trabajo de una cláusula WHEN NOT MATCHED BY SOURCE THEN UPDATE SET ..., que PostgreSQL 16 no tiene (llegó en la 17). En la 16 hay que hacerlo en dos sentencias dentro de la misma transacción: el MERGE (o el upsert) y después un UPDATE ... WHERE email NOT IN (SELECT email FROM clientes_campana). Ojo con ese NOT IN y los nulos: 04-02 lo advirtió, y aquí clientes_campana.email es PRIMARY KEY, así que es seguro. Además, clientes no tiene columna de baja, así que en TiendaVerde la operación ni siquiera sería expresable sin un ALTER TABLE previo — próxima lección.

Solución 3

Los tres errores:

# Error Síntoma
1 ON CONFLICT (id) cuando el INSERT no indica ningún id Cada fila recibe un id nuevo de la secuencia, así que nunca hay conflicto: se insertan tres productos duplicados en lugar de actualizar los existentes. No da ningún error
2 SET precio = precio sin EXCLUDED. Asignación circular: le asigna a precio su propio valor. No da error y no hace nada. Debía ser EXCLUDED.precio
3 La miel aparece dos veces en el mismo VALUES Con el destino de conflicto corregido, PostgreSQL falla con ON CONFLICT DO UPDATE command cannot affect row a second time

Hay un cuarto detalle que no es un error de sintaxis pero sí de criterio: SET stock = stock + EXCLUDED.stock funciona porque stock sin cualificar se resuelve a la fila existente, pero es ambiguo de leer. Escribe siempre productos.stock + EXCLUDED.stock.

Qué haría falta en el esquema. Para emparejar por nombre hace falta la restricción del apartado 5:

ALTER TABLE productos ADD CONSTRAINT uq_productos_nombre UNIQUE (nombre);

La versión corregida, con el origen deduplicado (nos quedamos con el último envío de la miel, el de 10,50 €, y sumamos las dos cantidades: 40 + 25 = 65):

-- ✅ CORRECTA
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock) VALUES
('Miel de azahar cruda 500 g', 1, 2, 10.50, 5.75, 65),
('Pasta de espelta 500 g',     1, 2,  2.95, 1.40, 80)
ON CONFLICT (nombre) DO UPDATE
SET precio = EXCLUDED.precio,
    coste  = EXCLUDED.coste,
    stock  = productos.stock + EXCLUDED.stock
WHERE productos.precio IS DISTINCT FROM EXCLUDED.precio
   OR EXCLUDED.stock > 0
RETURNING id, nombre, precio, coste, stock, (xmax = 0) AS alta_nueva;
id nombre precio coste stock alta_nueva
3 Miel de azahar cruda 500 g 10.50 5.75 145 false
4 Pasta de espelta 500 g 2.95 1.40 230 false
INSERT 0 2

Los dos productos existían (ids 3 y 4, de BioSierra Ibérica): la miel pasa de 9,75 € a 10,50 € y de 80 a 145 unidades (80 + 65); la pasta pasa de 2,80 € a 2,95 € y de 150 a 230 unidades (150 + 80).

Y la lección de fondo del ejercicio: de los tres errores, dos no producen ningún mensaje. El ON CONFLICT (id) habría duplicado el catálogo en silencio y el SET precio = precio habría dejado los precios sin tocar sin que nadie se enterara. Solo el tercero —el duplicado en el origen— provoca un error visible. Un upsert mal escrito falla callado mucho más a menudo que ruidosamente, y por eso hay que comprobar el RETURNING fila a fila la primera vez que se pone en producción.

Conclusión

El upsert resuelve la operación que faltaba:

  • El problema: "inserta si no existe, actualiza si existe", omnipresente en sincronización de catálogos, recepción de mercancía, altas de cliente y valoraciones.
  • La solución ingenua es incorrecta: SELECT y luego decidir abre una condición de carrera entre la comprobación y la escritura. Dos sesiones ven "no existe" y las dos insertan. Una sentencia es atómica; dos no.
  • INSERT ... ON CONFLICT, con sus dos acciones: DO NOTHING (ideal para datos maestros reejecutables, INSERT 0 0 sin error) y DO UPDATE SET (el upsert propiamente dicho).
  • El destino del conflicto, por columna o por ON CONSTRAINT, y por qué exige una restricción única: sin el índice, PostgreSQL no podría decidir atómicamente y volvería la ventana de carrera.
  • EXCLUDED, la pseudotabla con la fila propuesta, frente a tabla.columna con la fila existente. De ahí salen todos los patrones: quedarse con el nuevo, conservar el viejo, GREATEST, y sobre todo acumular (stock = productos.stock + EXCLUDED.stock). Y su límite: el mismo valor de clave dos veces en una sentencia falla, hay que deduplicar el origen.
  • WHERE en el DO UPDATE con IS DISTINCT FROM (04-03) para no escribir cuando nada cambia: menos escrituras, menos bloqueos, auditoría honesta.
  • RETURNING con upsert, y el truco de xmax = 0 para distinguir alta de modificación, con sus reservas.
  • MERGE, el estándar SQL:2003 disponible desde PostgreSQL 15: WHEN MATCHED / WHEN NOT MATCHED, varias condiciones evaluadas en orden y la capacidad de borrar. Con sus tres limitaciones en PostgreSQL 16: sin RETURNING, sin WHEN NOT MATCHED BY SOURCE, y no inmune a la concurrencia.
  • Cuándo usar cada uno: ON CONFLICT para el caso clásico con concurrencia real; MERGE para reglas múltiples, emparejamientos sin clave única y portabilidad.
  • El soporte por motor, y el aviso sobre INSERT OR REPLACE de SQLite, que borra y recrea la fila: pierde columnas, cambia el rowid y dispara las cascadas de borrado.

Con esto has cerrado el DML: sabes crear, leer, insertar, modificar, borrar y fusionar. Todo ello sobre un esquema que hasta ahora ha sido inmutable: el mismo que creaste en 05-01 y que no has vuelto a tocar. Pero los esquemas cambian. Hace falta una columna nueva, un tipo se queda corto, una restricción llega tarde, un nombre resulta ser un error. En la última lección del módulo, Modificando el esquema: ALTER TABLE y migraciones seguras, aprenderás todas las operaciones de ALTER TABLE y —lo que de verdad separa una migración inocua de una caída de producción— cuáles bloquean la tabla y cuáles no, el patrón expand/contract para cambiar un esquema sin parar el servicio, y por qué ningún cambio de estructura debería escribirse jamás a mano en una consola de producción.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados