En el capítulo 10 apagué la tabla entera con un UPDATE sin
WHERE. Se arregló porque eran cinco filas de mentira.
Este capítulo es la red que hace que eso se pueda deshacer de verdad, y de paso resuelve la otra pregunta que aparece el primer día que cargas datos: ¿y si la fila ya está?
Una transacción es un "todo o nada"
Vamos con el ejemplo de siempre porque es el que se entiende sin explicar nada: mover plata de una cuenta a otra.
CREATE TABLE saldos (
id_cliente INTEGER PRIMARY KEY,
saldo REAL NOT NULL DEFAULT 0
);
INSERT INTO saldos (id_cliente, saldo) VALUES (1, 500.00), (2, 300.00);
SELECT * FROM saldos ORDER BY id_cliente;
id_cliente saldo ---------- ----- 1 500.0 2 300.0
Pasar 200 del cliente 1 al 2 son dos UPDATE. Y el problema está
en el medio: si entre el primero y el segundo se cae la luz, se cortó la red o
alguien mató el proceso, el cliente 1 se quedó sin sus 200 y el 2 nunca los
recibió. La plata se evaporó 😳
BEGIN; UPDATE saldos SET saldo = saldo - 200 WHERE id_cliente = 1; UPDATE saldos SET saldo = saldo + 200 WHERE id_cliente = 2; COMMIT; SELECT * FROM saldos ORDER BY id_cliente;
id_cliente saldo ---------- ----- 1 300.0 2 500.0
BEGIN abre la transacción, COMMIT la cierra y la
hace real. Entre esas dos líneas, o pasa todo o no pasa nada.
Si el proceso se muere en el medio, la base deshace lo que llevaba y las cuentas
quedan como estaban.
Y aquí está lo que de verdad te va a salvar el pellejo.
BEGIN; UPDATE saldos SET saldo = saldo - 300 WHERE id_cliente = 1; UPDATE saldos SET saldo = 0; ROLLBACK; SELECT * FROM saldos ORDER BY id_cliente;
id_cliente saldo ---------- ----- 1 300.0 2 500.0
Ese segundo UPDATE es el desastre del capítulo 10: sin
WHERE, saldo cero para todo el mundo. Y el
ROLLBACK lo deshizo. Los saldos siguen en 300 y 500 como si nada
hubiera pasado 🙌
Esa es la costumbre que quiero que te lleves de este capítulo:
BEGIN, haces tu cambio, miras con un SELECT, y recién
ahí COMMIT o ROLLBACK. Es un segundo más y
convierte cualquier metida de pata en un susto.
Abrir una transacción
| PostgreSQL | BEGIN; -- también START TRANSACTION; |
| MySQL | START TRANSACTION; -- también BEGIN; |
| SQL Server | BEGIN TRANSACTION; -- o BEGIN TRAN; |
| SQLite | BEGIN; -- también BEGIN TRANSACTION; |
COMMIT y ROLLBACK sí se llaman igual en los cuatro. Lo que cambia es cómo se abre, y SQL Server es el único que necesita la palabra TRANSACTION.
Deshacer solo un pedacito: SAVEPOINT
BEGIN; UPDATE saldos SET saldo = saldo + 10 WHERE id_cliente = 1; SAVEPOINT antes_del_lio; UPDATE saldos SET saldo = 999999 WHERE id_cliente = 2; ROLLBACK TO antes_del_lio; COMMIT; SELECT * FROM saldos ORDER BY id_cliente;
id_cliente saldo ---------- ----- 1 310.0 2 500.0
El primer UPDATE se quedó (el cliente 1 pasó de 300 a 310) y el
segundo se deshizo. Un SAVEPOINT es una marca a la que puedes
volver sin tirar toda la transacción, y sirve muchísimo en cargas largas: si el
paso 7 de 10 falla, vuelves al 6 y sigues, en vez de empezar de cero.
Está en los cuatro motores con el mismo nombre.
Lo que no se puede deshacer
Aquí hay una diferencia entre motores que muerde fuerte y que casi nadie avisa.
¿Un CREATE TABLE dentro de una transacción se puede deshacer?
| PostgreSQL | sí, el DDL es transaccional como todo lo demás |
| MySQL | NO: cualquier CREATE, ALTER o DROP confirma la transacción abierta sin avisar |
| SQL Server | sí |
| SQLite | sí |
Lo de MySQL se llama commit implícito y es de las cosas que más caro salen: crees que estás dentro de un BEGIN, metes un ALTER TABLE en el medio, y todo lo anterior quedó confirmado. Tu ROLLBACK ya no deshace nada y no te lo dijo nadie.
Y una que vale para los cuatro: por defecto, cada sentencia suelta es
su propia transacción. Eso se llama autocommit, y significa que un
DELETE sin BEGIN está confirmado en el momento en que
termina. No hay nada que deshacer.
Insertar algo que a lo mejor ya está
Cambio de tema, mismo capítulo, porque las dos cosas van juntas en la vida real: cargar datos.
Tienes una tabla de metas por canal y cada mes te llega el archivo nuevo. La mitad de los canales ya están y hay que actualizarlos; la otra mitad son nuevos y hay que insertarlos. Eso es un upsert: update si está, insert si no.
CREATE TABLE metas (
canal TEXT PRIMARY KEY,
meta REAL NOT NULL,
actualizada TEXT NOT NULL DEFAULT '2026-01-01'
);
INSERT INTO metas (canal, meta) VALUES ('Web', 150000), ('WhatsApp', 140000);
SELECT * FROM metas ORDER BY canal;
canal meta actualizada -------- -------- ----------- Web 150000.0 2026-01-01 WhatsApp 140000.0 2026-01-01
Si intentas insertar Web otra vez, ya sabes lo que pasa.
INSERT INTO metas (canal, meta) VALUES ('Web', 999999);
IntegrityError: UNIQUE constraint failed: metas.canal
La forma que sale sola es DELETE y después
INSERT, y es la forma mala: entre las dos sentencias la fila no
existe, así que si algo lee justo ahí, ve un hueco. Y si el
INSERT falla, borraste el dato bueno.
INSERT INTO metas (canal, meta) VALUES ('Web', 160000)
ON CONFLICT (canal) DO UPDATE SET meta = excluded.meta, actualizada = '2026-06-24';
SELECT * FROM metas ORDER BY canal;
canal meta actualizada -------- -------- ----------- Web 160000.0 2026-06-24 WhatsApp 140000.0 2026-01-01
Se lee tal cual: "insértalo, y si choca con la clave canal,
entonces actualiza". Ese excluded es la palabra clave: es
la fila que querías insertar, la que se quedó fuera. Así puedes
decir "ponle el valor nuevo" sin repetirlo.
INSERT INTO metas (canal, meta) VALUES ('Tienda', 120000)
ON CONFLICT (canal) DO UPDATE SET meta = excluded.meta, actualizada = '2026-06-24';
SELECT * FROM metas ORDER BY canal;
canal meta actualizada -------- -------- ----------- Tienda 120000.0 2026-01-01 Web 160000.0 2026-06-24 WhatsApp 140000.0 2026-01-01
La misma sentencia, sin tocar una letra, insertó Tienda porque no existía. Y
fíjate en su actualizada: se quedó en 2026-01-01, el
DEFAULT, porque el DO UPDATE no se ejecutó. Esa
columna te dice de un vistazo cuáles se actualizaron y cuáles nacieron 🔍
Insertar y, si ya está, actualizar
| PostgreSQL | INSERT ... ON CONFLICT (canal) DO UPDATE SET meta = EXCLUDED.meta |
| MySQL | INSERT ... ON DUPLICATE KEY UPDATE meta = VALUES(meta) |
| SQL Server | MERGE INTO ... WHEN MATCHED THEN UPDATE ... WHEN NOT MATCHED THEN INSERT ... |
| SQLite | INSERT ... ON CONFLICT (canal) DO UPDATE SET meta = excluded.meta |
PostgreSQL y SQLite se escriben idéntico, MySQL cambia las palabras y no te deja elegir qué clave, y SQL Server te obliga a un MERGE de seis líneas que además arrastra fama de tener rarezas. Si tu consulta tiene que correr en los cuatro, el upsert es de las primeras cosas que se rompe.
Y un aviso sobre el VALUES(meta) de MySQL: está marcado como
obsoleto desde la 8.0.20, y lo nuevo es ponerle alias a la fila y escribir
AS nueva ... ON DUPLICATE KEY UPDATE meta = nueva.meta. Si copias
código viejo de internet te va a salir un aviso 🫠
Las otras dos formas de SQLite, y una que muerde
INSERT OR IGNORE INTO metas (canal, meta) VALUES ('Web', 1);
SELECT * FROM metas ORDER BY canal;
canal meta actualizada -------- -------- ----------- Tienda 120000.0 2026-01-01 Web 160000.0 2026-06-24 WhatsApp 140000.0 2026-01-01
INSERT OR IGNORE es "si choca, no hagas nada". Web se quedó en
160000 y la sentencia no dio error. Sirve para cargar sin duplicar cuando lo que
ya está es lo bueno.
INSERT OR REPLACE INTO metas (canal, meta) VALUES ('Web', 170000);
SELECT * FROM metas ORDER BY canal;
canal meta actualizada -------- -------- ----------- Tienda 120000.0 2026-01-01 Web 170000.0 2026-01-01 WhatsApp 140000.0 2026-01-01
Ahora mira bien la columna actualizada de Web. Estaba en
2026-06-24 y volvió a 2026-01-01 😳
Porque INSERT OR REPLACE no actualiza: borra la fila
entera y mete una nueva. Todo lo que no nombraste vuelve a su
DEFAULT, y si la columna no tuviera DEFAULT quedaría
en NULL. Y lo peor: si otra tabla apuntaba a esa fila con una clave
foránea en cascada, el borrado se lleva por delante lo de allá.
Es de esas cosas que funcionan durante meses y un día te dejan sin datos. Si
lo que quieres es actualizar, usa ON CONFLICT DO UPDATE y nombra
las columnas 💛
El upsert que calcula
INSERT INTO metas (canal, meta) VALUES ('WhatsApp', 5000)
ON CONFLICT (canal) DO UPDATE SET meta = metas.meta + excluded.meta;
SELECT * FROM metas ORDER BY canal;
canal meta actualizada -------- -------- ----------- Tienda 120000.0 2026-01-01 Web 170000.0 2026-01-01 WhatsApp 145000.0 2026-01-01
WhatsApp pasó de 140.000 a 145.000: no lo reemplazó, le sumó. En el
DO UPDATE tienes las dos filas a mano, metas.meta es
la vieja y excluded.meta es la nueva, y puedes hacer con ellas lo
que quieras.
Así se llevan los acumulados sin leer primero: es una sola ida a la base en
vez de un SELECT, una cuenta y un UPDATE. Menos
código y, sobre todo, sin el hueco de tiempo en el que otro puede meterse.
Y el upsert también funciona con un SELECT en vez de
VALUES, que es como se cargan tablas enteras.
INSERT INTO metas (canal, meta) SELECT canal, ROUND(SUM(monto) * 1.1, 2) FROM pedidos GROUP BY canal ON CONFLICT (canal) DO UPDATE SET meta = excluded.meta, actualizada = '2026-06-24'; SELECT * FROM metas ORDER BY canal;
canal meta actualizada ----------- --------- ----------- Marketplace 149329.45 2026-01-01 Tienda 130333.58 2026-06-24 Web 154490.79 2026-06-24 WhatsApp 151765.42 2026-06-24
Las metas del año que viene, calculadas como la venta real más un 10%, en una sola sentencia. Los tres canales que ya estaban se actualizaron y Marketplace entró nuevo, y se distingue por la fecha.
Esta es de las consultas que más se parecen a un trabajo de verdad de todo el libro 🌟
Insertar y, si ya está, no hacer nada
| PostgreSQL | INSERT ... ON CONFLICT DO NOTHING |
| MySQL | INSERT IGNORE INTO ... |
| SQL Server | no lo tiene: se resuelve con MERGE o con IF NOT EXISTS |
| SQLite | INSERT OR IGNORE ... -- y también ON CONFLICT DO NOTHING |
Cuidado con el INSERT IGNORE de MySQL, que es más ancho de lo que parece: se traga también otros errores, como un texto demasiado largo o una fecha inválida, y los convierte en avisos. Lo que querías ignorar era el duplicado, no todo.
Y el MERGE de SQL Server, para que veas que no está en los
otros.
MERGE INTO metas AS t USING (SELECT 'Web' AS canal) AS s ON t.canal = s.canal WHEN MATCHED THEN UPDATE SET meta = 1;
OperationalError: near "MERGE": syntax error
MERGE es del estándar y lo tienen SQL Server y PostgreSQL desde
la 15. MySQL y SQLite no. Es más potente que el upsert (puede insertar,
actualizar y borrar en la misma sentencia) y también bastante más difícil de
leer.
Cuando dos personas escriben a la vez
Todo lo de arriba asume que estás tú sola. En una base de verdad hay veinte procesos escribiendo, y ahí es donde las transacciones dejan de ser una comodidad y pasan a ser lo que sostiene todo.
El nombre elegante es aislamiento: cuánto se enteran unas transacciones de lo que están haciendo las otras mientras van a medias.
El nivel de aislamiento por defecto
| PostgreSQL | READ COMMITTED |
| MySQL | REPEATABLE READ |
| SQL Server | READ COMMITTED |
| SQLite | SERIALIZABLE, porque solo deja escribir a uno a la vez |
READ COMMITTED significa que solo ves lo que otros ya confirmaron. REPEATABLE READ, el de MySQL, además te garantiza que si lees dos veces lo mismo dentro de la transacción te sale igual. Que el default no sea el mismo explica por qué el mismo código se comporta distinto al cambiar de motor, y es de las cosas más difíciles de depurar que hay.
¿Qué se bloquea mientras escribes?
| PostgreSQL | la fila |
| MySQL | la fila, con InnoDB |
| SQL Server | la fila, la página o la tabla, según lo que decida el motor |
| SQLite | la BASE ENTERA: el que llega segundo recibe "database is locked" |
Esto es lo que hace que SQLite sea perfecta para aprender, para una app de escritorio o para un archivo de análisis, y mala idea para una web con gente escribiendo a la vez. No es un defecto, es la decisión de diseño que la hace caber en un archivo.
Ejercicios
Siete, y estos escriben. Sobre tu copia 💛
1. El UPDATE que se deshace
Sube todos los saldos un 10% y déjalos como estaban.
BEGIN; UPDATE saldos SET saldo = saldo * 1.10; ROLLBACK; SELECT id_cliente, ROUND(saldo, 2) AS saldo FROM saldos ORDER BY id_cliente;
id_cliente saldo ---------- ----- 1 310.0 2 500.0
310 y 500, los mismos de antes. El UPDATE tocó las dos filas y el
ROLLBACK las devolvió.
En tu cliente de SQL mete un SELECT entre el UPDATE
y el ROLLBACK para verlos subidos antes de decidir. Aquí no lo pongo
porque cada bloque del libro publica solo lo que devuelve su última sentencia, y
quiero que la última sea la prueba de que no quedó nada.
2. Un upsert de metas por segmento
Crea una tabla de metas por segmento y llénala con la venta real de cada uno más un 15%, de forma que se pueda volver a correr sin duplicar.
CREATE TABLE metas_segmento (
segmento TEXT PRIMARY KEY,
meta REAL NOT NULL,
veces INTEGER NOT NULL DEFAULT 1
);
INSERT INTO metas_segmento (segmento, meta)
SELECT c.segmento, ROUND(SUM(p.monto) * 1.15, 2)
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.segmento
ON CONFLICT (segmento) DO UPDATE SET meta = excluded.meta, veces = metas_segmento.veces + 1;
SELECT * FROM metas_segmento ORDER BY meta DESC;
segmento meta veces ---------- --------- ----- Horeca 174687.46 1 Bodega 166565.91 1 Mayorista 133456.91 1 Minimarket 120348.63 1
Esa columna veces es un truco que uso mucho: te dice cuántas
veces se recargó cada fila. La primera corrida deja todo en 1.
3. La misma carga, otra vez
Corre exactamente la misma sentencia del ejercicio 2 y mira qué cambia.
INSERT INTO metas_segmento (segmento, meta) SELECT c.segmento, ROUND(SUM(p.monto) * 1.15, 2) FROM clientes c JOIN pedidos p ON p.id_cliente = c.id GROUP BY c.segmento ON CONFLICT (segmento) DO UPDATE SET meta = excluded.meta, veces = metas_segmento.veces + 1; SELECT * FROM metas_segmento ORDER BY meta DESC;
segmento meta veces ---------- --------- ----- Horeca 174687.46 2 Bodega 166565.91 2 Mayorista 133456.91 2 Minimarket 120348.63 2
Cuatro filas, las mismas, y veces en 2. Eso se llama que la carga
es idempotente: la corres una vez o diez y el resultado es el
mismo. Es la propiedad más valiosa que puede tener un proceso de datos, porque
el día que algo falle a la mitad, vuelves a lanzarlo y ya 🌟
4. El REPLACE que borra sin querer
Comprueba en tu tabla de metas por segmento que
INSERT OR REPLACE se lleva puesta la columna que no nombraste.
INSERT OR REPLACE INTO metas_segmento (segmento, meta) VALUES ('Horeca', 1000);
SELECT * FROM metas_segmento ORDER BY segmento;
segmento meta veces ---------- --------- ----- Bodega 166565.91 2 Horeca 1000.0 1 Mayorista 133456.91 2 Minimarket 120348.63 2
Horeca perdió su veces y volvió a 1, porque la fila se borró y
nació otra. Los demás siguen en 2.
Con ON CONFLICT DO UPDATE SET meta = excluded.meta eso no habría
pasado: lo que no nombras, no se toca.
5. La carga que falla a la mitad
Inserta un segmento nuevo dentro de una transacción, deshazla, y comprueba cuántas filas quedaron.
BEGIN;
INSERT INTO metas_segmento (segmento, meta) VALUES ('Nuevo', 500);
ROLLBACK;
SELECT COUNT(*) AS filas FROM metas_segmento;
filas ----- 4
Cuatro: el 'Nuevo' no quedó. En una carga de verdad esto es lo que hace la diferencia entre "no se cargó nada, vuelve a lanzarlo" y "se cargó a medias y ahora hay que averiguar por dónde iba" 😵💫
6. Un contador que no se lee antes
Lleva la cuenta de cuántas veces se ha visto cada canal,
sumando de a uno, sin hacer un SELECT previo.
CREATE TABLE visitas_canal (canal TEXT PRIMARY KEY, veces INTEGER NOT NULL);
INSERT INTO visitas_canal (canal, veces) VALUES ('Web', 1)
ON CONFLICT (canal) DO UPDATE SET veces = visitas_canal.veces + 1;
INSERT INTO visitas_canal (canal, veces) VALUES ('Web', 1)
ON CONFLICT (canal) DO UPDATE SET veces = visitas_canal.veces + 1;
INSERT INTO visitas_canal (canal, veces) VALUES ('WhatsApp', 1)
ON CONFLICT (canal) DO UPDATE SET veces = visitas_canal.veces + 1;
SELECT * FROM visitas_canal ORDER BY canal;
canal veces -------- ----- Web 2 WhatsApp 1
Web en 2 y WhatsApp en 1, con la misma sentencia repetida. Sin leer antes, sin
saber si la fila existe, y sin un hueco de tiempo donde otro proceso pueda
meterse entre el SELECT y el UPDATE.
7. Escríbelo para los cuatro
Sin ejecutar: subir la meta de Web a 200.000, insertándola si no existiera, en los cuatro motores.
-- PostgreSQL y SQLite (idénticos)
INSERT INTO metas (canal, meta) VALUES ('Web', 200000)
ON CONFLICT (canal) DO UPDATE SET meta = EXCLUDED.meta;
-- MySQL
INSERT INTO metas (canal, meta) VALUES ('Web', 200000) AS nueva
ON DUPLICATE KEY UPDATE meta = nueva.meta;
-- SQL Server
MERGE INTO metas AS destino
USING (VALUES ('Web', 200000)) AS origen (canal, meta)
ON destino.canal = origen.canal
WHEN MATCHED THEN UPDATE SET destino.meta = origen.meta
WHEN NOT MATCHED THEN INSERT (canal, meta) VALUES (origen.canal, origen.meta);
Dos líneas en tres motores y cinco en SQL Server. Y ojo al punto y coma final
del MERGE: es obligatorio, cosa que no pasa con ninguna otra
sentencia de T-SQL, y es de los errores que más rabia dan porque el mensaje no
te dice eso.
Si estás escribiendo algo que tiene que correr en los cuatro, este es el sitio donde toca partir el código en dos caminos. No hay forma de escribirlo una sola vez 🫠
Lo que te llevas
- 🔒
BEGIN, cambio,SELECTpara mirar, y recién ahíCOMMIToROLLBACK. Es la costumbre que convierte un desastre en un susto. - 📍
SAVEPOINTyROLLBACK TOdeshacen un pedacito sin tirar toda la transacción. Están en los cuatro. - ⚠️ En MySQL, un
CREATE,ALTERoDROPconfirma la transacción abierta sin avisar. En los otros tres, el DDL se puede deshacer. - 🔁 El upsert es
ON CONFLICT DO UPDATEen PostgreSQL y SQLite,ON DUPLICATE KEY UPDATEen MySQL yMERGEen SQL Server. - 🎯
excludedes la fila que querías insertar, y con ella puedes sumar en vez de reemplazar. - 💣
INSERT OR REPLACEborra la fila y crea otra: lo que no nombras vuelve alDEFAULT. No es un update. - ♻️ Una carga con upsert es idempotente: la corres diez veces y da lo mismo.
- 🚦 SQLite bloquea la base entera al escribir. Por eso es genial para aprender y mala para una web con mucha gente escribiendo.
En el capítulo 12 vamos a por qué una consulta tarda: índices,
EXPLAIN y cómo leer lo que la base te contesta cuando le preguntas
qué piensa hacer.
Que tengas lindo día! 🌸