Insertar o actualizar en SQL (upsert): ON CONFLICT y ON DUPLICATE KEY

Llega la mercancía: algunos productos ya existen y hay que sumarles stock; otros son nuevos y hay que darlos de alta. Hacerlo con un SELECT y luego un INSERT o un UPDATE obliga a dos viajes y deja una ventana para errores. El upsert lo resuelve en una sola sentencia, aunque cada motor lo escribe a su manera.

Todas las consultas usan la base de ejemplo «tienda» del curso (clientes, productos, pedidos y sus líneas). Las que funcionan en SQLite tienen un enlace para abrirlas en el editor de SQL, que la trae cargada.

ON CONFLICT: SQLite y PostgreSQL

Se intenta el INSERT; si choca con la clave (id), se ejecuta el UPDATE. Dentro, excluded es la fila que se intentaba insertar:

-- Llega mercancía: 10 SSD (ya existe, id 8) y 12 hubs USB-C (producto nuevo, id 16)
INSERT INTO productos (id, nombre, categoria_id, precio, stock)
VALUES (8,  'SSD 1 TB',  4, 94.90, 10),
       (16, 'Hub USB-C', 3, 34.90, 12)
ON CONFLICT (id) DO UPDATE SET stock = productos.stock + excluded.stock;

SELECT id, nombre, precio, stock FROM productos WHERE id IN (8, 16);

Ábrelo en el editor y cámbialo →

resultado · SQLiteid | nombre    | precio | stock
---+-----------+--------+------
 8 | SSD 1 TB  |   94.9 |    50
16 | Hub USB-C |   34.9 |    12

El SSD tenía 40 unidades y ahora 50; el hub USB-C es nuevo. PostgreSQL da lo mismo con la misma sentencia. Hay que escribir productos.stock con el nombre de la tabla: un stock a secas sería ambiguo.

El DO UPDATE admite un WHERE, por ejemplo para tocar la fila solo si el precio cambió:

-- Actualizar el precio solo si el nuevo es distinto; WHERE dentro de DO UPDATE
INSERT INTO productos (id, nombre, categoria_id, precio, stock)
VALUES (5, 'Teclado mecánico', 3, 89.50, 0),
       (6, 'Ratón inalámbrico', 3, 22.99, 0)
ON CONFLICT (id) DO UPDATE SET precio = excluded.precio
WHERE productos.precio <> excluded.precio;

SELECT id, nombre, precio, stock FROM productos WHERE id IN (5, 6);

Ábrelo en el editor y cámbialo →

resultado · SQLiteid | nombre            | precio | stock
---+-------------------+--------+------
 5 | Teclado mecánico  |   89.5 |    35
 6 | Ratón inalámbrico |  22.99 |    60

ON DUPLICATE KEY UPDATE: MySQL

MySQL tiene su propia sintaxis. Desde la 8.0.19, la fila nueva se nombra con un alias (AS nuevo); la forma antigua, VALUES(stock), está obsoleta:

INSERT INTO productos (id, nombre, categoria_id, precio, stock)
VALUES (8,  'SSD 1 TB',  4, 94.90, 10),
       (16, 'Hub USB-C', 3, 34.90, 12) AS nuevo
ON DUPLICATE KEY UPDATE stock = productos.stock + nuevo.stock;

SELECT id, nombre, precio, stock FROM productos WHERE id IN (8, 16);
resultado · MySQL+----+-----------+--------+-------+
| id | nombre    | precio | stock |
+----+-----------+--------+-------+
|  8 | SSD 1 TB  |  94.90 |    50 |
| 16 | Hub USB-C |  34.90 |    12 |
+----+-----------+--------+-------+

MERGE: el estándar, en PostgreSQL

PostgreSQL (desde la 15) tiene MERGE, la sentencia del estándar SQL, cómoda cuando los datos nuevos vienen de otra tabla:

-- MERGE (SQL estándar; PostgreSQL desde la versión 15)
CREATE TEMP TABLE llegada (id INTEGER, nombre VARCHAR(60), precio NUMERIC(10,2), unidades INTEGER);
INSERT INTO llegada VALUES (8, 'SSD 1 TB', 94.90, 10), (16, 'Hub USB-C', 34.90, 12);

MERGE INTO productos p
USING llegada l ON p.id = l.id
WHEN MATCHED THEN UPDATE SET stock = p.stock + l.unidades
WHEN NOT MATCHED THEN INSERT (id, nombre, categoria_id, precio, stock)
                      VALUES (l.id, l.nombre, 3, l.precio, l.unidades);

SELECT id, nombre, stock FROM productos WHERE id IN (8, 16) ORDER BY id;
resultado · PostgreSQL id |  nombre   | stock 
----+-----------+-------
  8 | SSD 1 TB  |    50
 16 | Hub USB-C |    12

La trampa de REPLACE

SQLite (INSERT OR REPLACE) y MySQL (REPLACE) parecen hacer lo mismo, pero no actualizan: borran la fila y crean otra. Las columnas que no escribas vuelven a su valor por defecto:

-- INSERT OR REPLACE parece un upsert, pero borra la fila y la vuelve a crear
SELECT id, nombre, categoria_id, precio, stock FROM productos WHERE id = 9;

INSERT OR REPLACE INTO productos (id, nombre, precio)
VALUES (9, 'Disco externo 4 TB', 109.00);

SELECT id, nombre, categoria_id, precio, stock FROM productos WHERE id = 9;

Ábrelo en el editor y cámbialo →

resultado · SQLiteid | nombre             | categoria_id | precio | stock
---+--------------------+--------------+--------+------
 9 | Disco externo 4 TB |            4 |    119 |    15

id | nombre             | categoria_id | precio | stock
---+--------------------+--------------+--------+------
 9 | Disco externo 4 TB |         NULL |    109 |     0

El disco externo ha perdido la categoría y las 15 unidades de stock. MySQL hace exactamente lo mismo:

resultado · MySQL+----+--------------------+--------------+--------+-------+
| id | nombre             | categoria_id | precio | stock |
+----+--------------------+--------------+--------+-------+
|  9 | Disco externo 4 TB |         NULL | 109.00 |     0 |
+----+--------------------+--------------+--------+-------+

Y en MySQL hay un detalle más: si existiera una clave foránea desde las líneas de pedido, el borrado que hace REPLACE fallaría. En la base del ejemplo no falla porque MySQL acepta pero ignora el REFERENCES escrito junto a la columna; hay que declarar la clave aparte:

-- En MySQL, REFERENCES escrito junto a la columna se acepta pero no crea la clave foránea.
-- Con la clave declarada aparte, REPLACE ya no puede borrar un producto que tiene ventas.
SELECT COUNT(*) AS claves_foraneas FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE();

ALTER TABLE detalle ADD FOREIGN KEY (producto_id) REFERENCES productos (id);

REPLACE INTO productos (id, nombre, precio) VALUES (9, 'Disco externo 4 TB', 109.00);
resultado · MySQL+-----------------+
| claves_foraneas |
+-----------------+
|               0 |
+-----------------+
ERROR 1451 (23000) at line 8: Cannot delete or update a parent row: a foreign key constraint fails (`tmy_4_replace_fk_sql_`.`detalle`, CONSTRAINT `detalle_ibfk_1` FOREIGN KEY (`producto_id`) REFERENCES `productos` (`id`))

Insertar solo si no existe

Con ON CONFLICT DO NOTHING, la fila que choca se descarta sin error y el resto se inserta:

-- Insertar solo si no existe
INSERT INTO categorias (id, nombre) VALUES (5, 'Redes'), (6, 'Software')
ON CONFLICT DO NOTHING;

SELECT id, nombre FROM categorias ORDER BY id;

Ábrelo en el editor y cámbialo →

resultado · SQLiteid | nombre
---+---------------
 1 | Portátiles
 2 | Monitores
 3 | Periféricos
 4 | Almacenamiento
 5 | Redes
 6 | Software

El equivalente de MySQL, INSERT IGNORE, va más allá: no solo ignora los duplicados, también convierte otros errores en avisos. Aquí, un nombre nulo en una columna NOT NULL se guarda como texto vacío:

-- INSERT IGNORE se salta el duplicado... y también otros errores, que pasan a ser avisos
INSERT IGNORE INTO categorias (id, nombre) VALUES (5, 'Redes'), (6, 'Software');
INSERT IGNORE INTO productos (id, nombre, categoria_id, precio, stock) VALUES (17, NULL, 3, 10.00, 1);
SHOW WARNINGS;
SELECT id, nombre FROM productos WHERE id = 17;
resultado · MySQL+---------+------+--------------------------------+
| Level   | Code | Message                        |
+---------+------+--------------------------------+
| Warning | 1048 | Column 'nombre' cannot be null |
+---------+------+--------------------------------+
+----+--------+
| id | nombre |
+----+--------+
| 17 |        |
+----+--------+
MotorInsertar o actualizarInsertar si no existe
SQLite 3.24+INSERT … ON CONFLICT (…) DO UPDATEON CONFLICT DO NOTHING
PostgreSQL 9.5+INSERT … ON CONFLICT (…) DO UPDATE o MERGE (15+)ON CONFLICT DO NOTHING
MySQL 8INSERT … AS nuevo ON DUPLICATE KEY UPDATEINSERT IGNORE, con cuidado

Todas las consultas de esta página se ejecutaron antes de publicarla sobre la base de ejemplo «tienda» del curso, en contenedores limpios: SQLite 3.46.1 (el de Python 3.13), PostgreSQL 17 y MySQL 8.4. Los resultados y los mensajes de error están copiados de esa ejecución. Documentación oficial: UPSERT en SQLite.