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
- El problema: insertar o actualizar según lo que haya
- Por qué la solución ingenua es incorrecta
INSERT ... ON CONFLICT: la sintaxisDO NOTHINGyDO UPDATE SET- El destino del conflicto y por qué exige una restricción única
- La pseudotabla
EXCLUDEDy el patrón de acumulación WHEREen elDO UPDATE: actualizar solo si algo cambiaRETURNINGcon upsert, y cómo saber si fue alta o modificaciónMERGE: el upsert del estándar SQLON CONFLICTfrente aMERGE- Soporte por motor
- Tres casos de TiendaVerde
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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.
- 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 → UPDATEFunciona 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.
INSERT ... ON CONFLICT: la sintaxis
INSERT ... ON CONFLICT: la sintaxisINSERT 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.
DO NOTHING y DO UPDATE SET
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;Cero filas insertadas, cero errores. El cliente ya existía con ese email, así que la sentencia se ha limitado a no hacer nada.
| id | nombre | apellidos | 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 | ciudad | fecha_registro | |
|---|---|---|---|---|---|
| 1 | Lucía | Martínez Soler | [email protected] | Alicante | 2025-01-10 |
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 | ciudad | fecha_registro | |
|---|---|---|---|---|---|
| 18 | Aitor | Zubizarreta Egaña | [email protected] | Bilbao | 2026-03-01 |
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
ides 18 y no 16. TiendaVerde tiene 15 clientes y su secuencia quedó en 15 tras elsetvaldel script. Pero un upsert que acaba en conflicto también consume un valor de la secuencia: PostgreSQL construye la fila completa —evaluando todos losDEFAULT, incluidonextval— antes 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
idtienen huecos grandes y no cuentan filas. Si eso te importa, revisa el tipo: unINTEGERse agota en 2 147 483 647, y con upserts masivos eso llega antes de lo que parece.BIGINTes la respuesta habitual.
- 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;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);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 |
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.
- La pseudotabla
EXCLUDED y el patrón de acumulación
EXCLUDED y el patrón de acumulaciónEXCLUDED 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.stockEse ú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".
| 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 |
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):
⚠️
EXCLUDEDno acumula entre tuplas de la misma sentencia. Si el fichero trajera dos veces el mismo nombre, PostgreSQL fallaría conON 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.
WHERE en el DO UPDATE: actualizar solo si algo cambia
WHERE en el DO UPDATE: actualizar solo si algo cambiaEl 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 |
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.
RETURNING con upsert, y cómo saber si fue alta o modificación
RETURNING con upsert, y cómo saber si fue alta o modificaciónRETURNING 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 | ciudad | fue_insercion | |
|---|---|---|---|---|
| 1 | Lucía | [email protected] | Alicante | false |
| 17 | Marisol | [email protected] | Málaga | true |
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.
xmaxes un detalle de implementación, no una interfaz documentada. Puede cambiar entre versiones, y hay situaciones (filas bloqueadas por otras transacciones,SELECT ... FOR UPDATEprevios) en las que unxmaxdistinto 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 |
Una de las dos. Si necesitas saber también qué se ignoró, DO NOTHING no te lo va a decir.
MERGE: el upsert del estándar SQL
MERGE: el upsert del estándar SQLON 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);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);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á.
| 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 sí 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.
ON CONFLICT frente a MERGE
ON CONFLICT frente a MERGEINSERT ... ON CONFLICT |
MERGE |
|
|---|---|---|
| Estándar SQL | No: extensión de PostgreSQL | Sí (SQL:2003) |
| Disponible desde | PostgreSQL 9.5 | PostgreSQL 15 |
| Exige restricción única | Sí, 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 |
Sí | 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 CONFLICTcuando el caso sea el clásico "insertar o actualizar" sobre una clave única, cuando haya concurrencia real, o cuando necesitesRETURNING. Es el 90 % de los casos.MERGEcuando 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.
- 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 | Sí |
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, nuncaINSERT OR REPLACE, salvo que el borrado-y-recreación sea exactamente lo que quieres.
- 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 |
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);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 |
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 |
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_nombreyuq_resenas_producto_cliente) no forman parte del esquema del curso. Quítalas conALTER TABLE ... DROP CONSTRAINT ...o, más simple, recargatiendaverde.sql. La sintaxis completa deALTER TABLEes 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 CONFLICTsobre 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 elDO UPDATE.SET precio = precioes 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 deIS DISTINCT FROMen elWHEREdelDO UPDATE. Con unNULLde por medio la comparación daUNKNOWNy la fila nunca se actualiza (04-03). - Esperar que
DO NOTHINGte informe de lo ignorado. No aparece en elRETURNING. Si necesitas saberlo, usaDO UPDATEconWHERE. - 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 REPLACEen SQLite creyendo que es un upsert. Borra y recrea: pierde columnas, cambia elrowidy dispara las cascadas de borrado. - Suponer que
MERGEes seguro frente a la concurrencia. No lo es en PostgreSQL 16: puede fallar con clave duplicada.ON CONFLICTsí 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(oMERGE) es el patrón profesional de integración. - Consejo: decide campo a campo qué se fusiona.
preciosí,fecha_altano,stockacumulando. Esa granularidad es todo el valor delDO UPDATE. - Consejo: pon siempre el
WHERE ... IS DISTINCT FROMen elDO UPDATE. Menos escrituras, menos bloqueos, auditoría honesta y unRETURNINGque dice la verdad.
Ejercicios
Trabaja sobre la base recién recargada, dentro de BEGIN … ROLLBACK.
Ejercicio 1
TiendaVerde recibe un fichero de altas y bajas de clientes procedente de una campaña de marketing:
| nombre | apellidos | 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:
fecha_registrodebe ser2026-03-02para los nuevos y no debe modificarse en los existentes.- La ciudad solo debe actualizarse si realmente cambia.
- 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:
- ¿Qué diferencia hay en la salida de
psql? - ¿Cuál de las dos es segura si dos procesos ejecutan la campaña a la vez?
- ¿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 | 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 |
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:
fecha_registrofuera delSET. Aparece en elINSERT(para los nuevos) pero no en elDO UPDATE, así que los existentes conservan la suya: 2025-01-10 y 2025-03-21. Es el mismo principio que confecha_altaen 12.2.WHERE clientes.ciudad IS DISTINCT FROM EXCLUDED.ciudad, no<>: si algún cliente tuviera la ciudad aNULL(la columna lo admite),<>daríaUNKNOWNy jamás se actualizaría.(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');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 | 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 |
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:
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 |
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:
SELECTy 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 0sin error) yDO 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 atabla.columnacon 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.WHEREen elDO UPDATEconIS DISTINCT FROM(04-03) para no escribir cuando nada cambia: menos escrituras, menos bloqueos, auditoría honesta.RETURNINGcon upsert, y el truco dexmax = 0para 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: sinRETURNING, sinWHEN NOT MATCHED BY SOURCE, y no inmune a la concurrencia.- Cuándo usar cada uno:
ON CONFLICTpara el caso clásico con concurrencia real;MERGEpara reglas múltiples, emparejamientos sin clave única y portabilidad. - El soporte por motor, y el aviso sobre
INSERT OR REPLACEde SQLite, que borra y recrea la fila: pierde columnas, cambia elrowidy 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
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
