DELETE elimina filas. Es la operación más sencilla del módulo y la que exige más criterio, porque plantea una pregunta que INSERT y UPDATE no plantean: ¿de verdad hay que borrarlo?
TiendaVerde ya te dio la respuesta sin decírtelo. Sus tablas productos y proveedores tienen una columna activo. Ese booleano existe porque, en un sistema real, un producto que se ha vendido no se borra nunca: se retira del catálogo. Borrarlo destruiría el histórico de facturación, y de hecho la clave foránea lineas_pedido.producto_id → productos.id está declarada ON DELETE RESTRICT precisamente para impedirlo. Toda esa discusión —borrado lógico frente a borrado físico— tiene aquí su lección.
Antes llegaremos a lo concreto: el protocolo de seguridad reforzado, la diferencia entre DELETE y TRUNCATE, y qué ocurre exactamente al borrar una fila de la que dependen otras, demostrado con recuentos antes y después para las tres acciones ON DELETE que TiendaVerde declara.
⚠️ Aviso de seguridad
DELETEes una operación destructiva e irreversible una vez confirmada. A diferencia deUPDATE, que sustituye un valor por otro,DELETEhace desaparecer la fila entera — y, conON DELETE CASCADE, filas de otras tablas que no has mencionado.
- Ejecuta todos los ejemplos sobre tu base de datos de prácticas (
tiendaverde), nunca en producción.- Haz una copia de seguridad previa:
pg_dump -U curso_sql -d tiendaverde -f copia.sql. Para volver al estado inicial basta con relanzartiendaverde.sql.- Trabaja siempre dentro de
BEGIN…ROLLBACK/COMMITmientras experimentas.- En un sistema real, un borrado sobre datos vivos debe ir revisado por otra persona, y las políticas de borrado de datos personales requieren revisión legal (apartado 9).
Contenido
- La sintaxis, y el protocolo reforzado
DELETEsinWHEREfrente aTRUNCATE- Borrar una fila referenciada:
RESTRICT - Borrar una fila referenciada:
CASCADE - Borrar una fila referenciada:
SET NULL - El peligro del
CASCADEy la alternativa profesional DELETE ... USING: borrar según otra tablaRETURNING: conservar lo que borras- Borrado lógico frente a borrado físico
- Datos personales y derecho de supresión
- Recuperación tras un borrado accidental
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- La sintaxis, y el protocolo reforzado
Solo dos piezas: qué tabla y qué filas. No hay SET, no hay valores. Toda la responsabilidad recae sobre el WHERE.
Igual que UPDATE N, el número te dice cuántas filas ha eliminado. Y aquí importa todavía más, porque no hay ningún valor nuevo que puedas inspeccionar después: si el número no es el que esperabas, ya es tarde.
El protocolo de los cinco pasos de 05-03 se aplica igual, con dos refuerzos:
| Paso | En UPDATE |
En DELETE |
|---|---|---|
| 1 | SELECT con el WHERE definitivo |
Igual, pero con SELECT *: quieres ver la fila entera que va a desaparecer |
| 2 | Contar filas | Igual |
| 3 | Previsualizar los valores nuevos | Sustituido por: comprobar qué filas dependientes se llevará por delante |
| 4 | UPDATE copiando el WHERE |
DELETE copiando el WHERE, siempre dentro de BEGIN |
| 5 | Verificar | Verificar recuentos de todas las tablas afectadas, no solo de la que borras |
El paso 3 es el nuevo, y es el que distingue un DELETE seguro de una catástrofe. Antes de borrar un pedido, mira cuántas líneas y devoluciones tiene:
SELECT (SELECT COUNT(*) FROM lineas_pedido WHERE pedido_id = 6) AS lineas,
(SELECT COUNT(*) FROM devoluciones WHERE pedido_id = 6) AS devoluciones;| lineas | devoluciones |
|---|---|
| 2 | 1 |
Ahora ya sabes que ese DELETE 1 va a eliminar cuatro filas en tres tablas. El apartado 4 lo demuestra.
Y el flujo completo:
flowchart TD
A["SELECT * con el WHERE<br/>ver las filas enteras"] --> B["Contar filas dependientes<br/>en las tablas hijas"]
B --> C{"¿Los números<br/>son los esperados?"}
C -->|No| A
C -->|Sí| D["BEGIN"]
D --> E["DELETE"]
E --> F["Recuento en TODAS<br/>las tablas afectadas"]
F --> G{"¿Coincide?"}
G -->|No| H["ROLLBACK"]
G -->|Sí| I["COMMIT"]
H --> A
DELETE sin WHERE frente a TRUNCATE
DELETE sin WHERE frente a TRUNCATESin WHERE, DELETE vacía la tabla:
Las doce reseñas, fuera. Exactamente el mismo problema que el UPDATE sin WHERE, con la misma ausencia de aviso.
Para vaciar una tabla existe una instrucción específica, TRUNCATE:
Hacen lo mismo y no se parecen en nada:
DELETE FROM tabla |
TRUNCATE TABLE tabla |
|
|---|---|---|
| Sublenguaje | DML | DDL |
Admite WHERE |
Sí | No: es todo o nada |
| Velocidad | Proporcional al número de filas | Casi instantánea, independiente del volumen |
| Cómo lo hace | Marca cada fila como borrada, una a una | Descarta los ficheros de datos completos |
| Registro de escritura (WAL) | Una entrada por fila | Mínimo |
| Espacio en disco | No se libera hasta un VACUUM |
Se libera de inmediato |
| Transaccional en PostgreSQL | Sí | Sí (se puede hacer ROLLBACK) |
| Transaccional en MySQL | Sí | No: confirma implícitamente |
Dispara TRIGGER de fila |
Sí (BEFORE/AFTER DELETE) |
No (solo triggers de sentencia) |
| Reinicia la identidad | No | Opcional: TRUNCATE ... RESTART IDENTITY |
| Devuelve el número de filas | Sí (DELETE 12) |
No |
RETURNING |
Sí | No |
| Permiso necesario | DELETE |
TRUNCATE (más restrictivo) |
| Respeta las FK | Sí, aplica las acciones ON DELETE |
Falla si hay FK apuntando a la tabla, salvo CASCADE |
Esa última fila merece una demostración:
ERROR: cannot truncate a table referenced in a foreign key constraint DETAIL: Table "lineas_pedido" references "pedidos". HINT: Truncate table "lineas_pedido" at the same time, or use TRUNCATE ... CASCADE.
TRUNCATE no ejecuta las acciones ON DELETE: o vacías todo el árbol a la vez, o no vacías nada.
NOTICE: truncate cascades to table "lineas_pedido" NOTICE: truncate cascades to table "devoluciones" TRUNCATE TABLE
Y si además quieres que las secuencias vuelvan a empezar en 1:
Cuándo usar cada uno.
TRUNCATEpara vaciar tablas de trabajo, de staging o de pruebas, donde quieres empezar de cero y la velocidad importa.DELETEpara todo lo demás, y siempre que haya unWHEREde por medio. UnDELETE FROM tabla;sin condición sobre una tabla grande es lo peor de los dos mundos: lento, con el WAL disparado y sin liberar espacio.
- Borrar una fila referenciada:
RESTRICT
RESTRICTAquí empieza lo interesante. Cuando la fila que borras es el "padre" de una clave foránea, el motor aplica la acción declarada en el ON DELETE. TiendaVerde declara las tres.
RESTRICT es la barrera: prohíbe borrar. La lleva lineas_pedido.producto_id, entre otras.
El producto 1 (Aceite de oliva) aparece en 5 líneas de pedido y tiene 2 reseñas:
SELECT (SELECT COUNT(*) FROM lineas_pedido WHERE producto_id = 1) AS lineas,
(SELECT COUNT(*) FROM resenas WHERE producto_id = 1) AS resenas;| lineas | resenas |
|---|---|
| 5 | 2 |
ERROR: update or delete on table "productos" violates foreign key constraint "lineas_pedido_producto_id_fkey" on table "lineas_pedido" DETAIL: Key (id)=(1) is still referenced from table "lineas_pedido".
Léelo con calma, porque es el error más frecuente del módulo:
| Fragmento | Qué significa |
|---|---|
update or delete on table "productos" |
La tabla que intentabas tocar |
violates foreign key constraint "lineas_pedido_producto_id_fkey" |
Qué FK lo impide |
on table "lineas_pedido" |
Dónde está esa FK: en la tabla hija |
Key (id)=(1) is still referenced |
El valor que sigue en uso |
Compáralo con el error de INSERT de 05-02: allí decía is not present in table (referenciabas algo que no existe); aquí dice is still referenced from table (algo depende de lo que quieres borrar). Las dos mitades de la integridad referencial.
Ahora un producto que sí se puede borrar. El 19 (Desodorante natural) es uno de los tres que nunca se han vendido, y no tiene reseñas:
BEGIN;
SELECT (SELECT COUNT(*) FROM lineas_pedido WHERE producto_id = 19) AS lineas,
(SELECT COUNT(*) FROM resenas WHERE producto_id = 19) AS resenas;| lineas | resenas |
|---|---|
| 0 | 0 |
DELETE FROM productos WHERE id = 19
RETURNING id, nombre, categoria_id, proveedor_id, precio, stock;| id | nombre | categoria_id | proveedor_id | precio | stock |
|---|---|---|---|---|---|
| 19 | Desodorante natural en barra 50 g | 5 | 4 | 7.80 | 75 |
| productos |
|---|
| 19 |
Del catálogo de 20 quedan 19. La restricción RESTRICT no es un obstáculo: es una función. Te está diciendo "este producto tiene historia, no lo destruyas". Y el producto 19 no la tiene.
- Borrar una fila referenciada:
CASCADE
CASCADECASCADE propaga el borrado a las filas hijas. En TiendaVerde lo llevan cuatro claves foráneas: lineas_pedido.pedido_id, devoluciones.pedido_id, resenas.producto_id y resenas.cliente_id.
El pedido 6 es el caso perfecto: está cancelado, tiene 2 líneas y 1 devolución.
BEGIN;
-- Recuento ANTES
SELECT (SELECT COUNT(*) FROM pedidos) AS pedidos,
(SELECT COUNT(*) FROM lineas_pedido) AS lineas,
(SELECT COUNT(*) FROM devoluciones) AS devoluciones;| pedidos | lineas | devoluciones |
|---|---|---|
| 20 | 47 | 3 |
Y lo que cuelga del pedido 6:
SELECT lp.id, lp.producto_id, p.nombre, lp.cantidad, lp.precio_unitario
FROM lineas_pedido AS lp
JOIN productos AS p ON lp.producto_id = p.id
WHERE lp.pedido_id = 6
ORDER BY lp.id;| id | producto_id | nombre | cantidad | precio_unitario |
|---|---|---|---|---|
| 14 | 1 | Aceite de oliva virgen extra 500 ml | 1 | 12.50 |
| 15 | 8 | Aceite corporal de almendras 200 ml | 1 | 14.25 |
| id | pedido_id | motivo | fecha | importe |
|---|---|---|---|---|
| 1 | 6 | Pedido cancelado por el cliente antes del envío | 2025-05-25 | 26.75 |
Ahora el borrado:
DELETE 1. Una sola fila, dice PostgreSQL. Miremos los recuentos:
SELECT (SELECT COUNT(*) FROM pedidos) AS pedidos,
(SELECT COUNT(*) FROM lineas_pedido) AS lineas,
(SELECT COUNT(*) FROM devoluciones) AS devoluciones;| pedidos | lineas | devoluciones |
|---|---|---|
| 19 | 45 | 2 |
Han desaparecido cuatro filas en tres tablas, y el contador solo ha informado de una. Ahí está, en toda su crudeza, el peligro del CASCADE: DELETE N cuenta las filas que tú has borrado, no las que el motor ha borrado en cadena.
flowchart TD
A["DELETE FROM pedidos<br/>WHERE id = 6"] --> B["pedidos: 20 → 19<br/>DELETE 1"]
B --> C["lineas_pedido.pedido_id<br/>ON DELETE CASCADE"]
B --> D["devoluciones.pedido_id<br/>ON DELETE CASCADE"]
C --> E["lineas 14 y 15<br/>47 → 45"]
D --> F["devolución 1<br/>3 → 2"]
E --> G["Total real:<br/>4 filas en 3 tablas"]
F --> G
Y las cascadas pueden encadenarse. Si lineas_pedido tuviera a su vez una tabla hija con CASCADE, el borrado seguiría bajando. En un esquema grande, un solo DELETE puede propagarse por media base de datos sin que nada te lo indique.
Comprobar el alcance antes de borrar
La forma de no llevarte sorpresas es preguntar al catálogo del sistema qué claves foráneas apuntan a una tabla:
SELECT tc.table_name AS tabla_hija,
kcu.column_name AS columna,
rc.delete_rule AS accion_on_delete
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.referential_constraints AS rc
ON tc.constraint_name = rc.constraint_name
JOIN information_schema.constraint_column_usage AS ccu
ON rc.unique_constraint_name = ccu.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND ccu.table_name = 'pedidos'
ORDER BY tabla_hija;| tabla_hija | columna | accion_on_delete |
|---|---|---|
| devoluciones | pedido_id | CASCADE |
| lineas_pedido | pedido_id | CASCADE |
Más rápido todavía, dentro de psql, la sección Referenced by de \d pedidos:
Referenced by:
TABLE "devoluciones" CONSTRAINT "devoluciones_pedido_id_fkey" FOREIGN KEY (pedido_id) REFERENCES pedidos(id) ON DELETE CASCADE
TABLE "lineas_pedido" CONSTRAINT "lineas_pedido_pedido_id_fkey" FOREIGN KEY (pedido_id) REFERENCES pedidos(id) ON DELETE CASCADEConsejo: haz
\d tablaantes de cualquierDELETEsobre una tabla que no conozcas al dedillo. La secciónReferenced byte dice en cinco segundos si vas a desencadenar una cascada.
- Borrar una fila referenciada:
SET NULL
SET NULLSET NULL no borra al hijo: le quita la referencia. TiendaVerde lo declara en pedidos.empleado_id, clientes.referido_por_id y empleados.jefe_id.
El comercial Óscar Peris Blasco (empleado 4) deja la empresa. Tiene 4 pedidos asignados:
BEGIN;
SELECT id, cliente_id, empleado_id, fecha_pedido, estado
FROM pedidos WHERE empleado_id = 4 ORDER BY id;| id | cliente_id | empleado_id | fecha_pedido | estado |
|---|---|---|---|---|
| 2 | 2 | 4 | 2025-03-12 | entregado |
| 6 | 5 | 4 | 2025-05-23 | cancelado |
| 10 | 9 | 4 | 2025-08-03 | entregado |
| 16 | 4 | 4 | 2025-12-19 | enviado |
| pedidos_sin_empleado |
|---|
| 10 |
| id | nombre | apellidos | puesto | jefe_id | salario |
|---|---|---|---|---|---|
| 4 | Óscar | Peris Blasco | Comercial | 2 | 28500.00 |
SELECT (SELECT COUNT(*) FROM empleados) AS empleados,
(SELECT COUNT(*) FROM pedidos) AS pedidos,
(SELECT COUNT(*) FROM pedidos WHERE empleado_id IS NULL) AS sin_empleado;| empleados | pedidos | sin_empleado |
|---|---|---|
| 7 | 20 | 14 |
Los 20 pedidos siguen ahí. Lo que ha cambiado es que los cuatro de Óscar ahora tienen empleado_id a NULL: de 10 pedidos sin comercial hemos pasado a 14.
Y aquí hay una lección de 04-03 que conviene subrayar. Antes, empleado_id IS NULL significaba inequívocamente "pedido entrado por la web". Ahora significa dos cosas distintas: "pedido web" o "el comercial que lo gestionó ya no está en la empresa". El NULL ha perdido precisión semántica sin que nadie lo haya decidido. Es un efecto secundario clásico de SET NULL, y la razón de que muchos equipos prefieran conservar al empleado con una marca de baja en lugar de borrarlo.
SET NULL en una relación reflexiva
Un caso más vistoso: borrar a Andrés Company Talens (empleado 2), Responsable de ventas, que no gestiona ningún pedido pero tiene tres subordinados.
| id | nombre | apellidos | puesto | jefe_id |
|---|---|---|---|---|
| 4 | Óscar | Peris Blasco | Comercial | 2 |
| 5 | Laia | Puig Sanchis | Comercial | 2 |
| 6 | Marc | Estévez Roig | Atención al cliente | 2 |
| id | empleado | puesto | jefe_id |
|---|---|---|---|
| 1 | Rosa Alcázar Vives | Directora general | (null) |
| 3 | Beatriz Nadal Ripoll | Responsable de logística | 1 |
| 4 | Óscar Peris Blasco | Comercial | (null) |
| 5 | Laia Puig Sanchis | Comercial | (null) |
| 6 | Marc Estévez Roig | Atención al cliente | (null) |
| 7 | Irene Salvador Mira | Operaria de almacén | 3 |
| 8 | Daniel Vercher Lluch | Analista de datos | 1 |
Tres empleados se han quedado sin jefe, y el organigrama que 01-06 dibujaba con tanta pulcritud tiene ahora cuatro raíces en lugar de una. La empresa no se ha reorganizado: simplemente ha desaparecido un nodo intermedio del árbol y SET NULL ha hecho lo único que sabe hacer.
Las tres acciones, en una tabla
| Acción | Qué le pasa al hijo | Cuándo elegirla | En TiendaVerde |
|---|---|---|---|
RESTRICT / NO ACTION |
Nada: se impide el borrado | El hijo no puede quedarse sin padre y el padre no debería desaparecer | pedidos.cliente_id, lineas_pedido.producto_id, productos.categoria_id, productos.proveedor_id |
CASCADE |
Se borra también | El hijo es parte del padre (composición): no existe sin él | lineas_pedido.pedido_id, devoluciones.pedido_id, resenas.producto_id, resenas.cliente_id |
SET NULL |
Pierde la referencia, sobrevive | La relación es opcional y el hijo tiene sentido propio | pedidos.empleado_id, clientes.referido_por_id, empleados.jefe_id |
- El peligro del
CASCADE y la alternativa profesional
CASCADE y la alternativa profesionalCASCADE es cómodo. Un DELETE y todo el árbol desaparece limpiamente, sin filas huérfanas. Y precisamente por eso es peligroso:
| Riesgo | Detalle |
|---|---|
| Alcance invisible | DELETE 1 puede significar cuatro filas, o cuatrocientas mil. El contador no lo dice |
| Se propaga en cadena | Si el hijo tiene hijos con CASCADE, el borrado sigue bajando sin límite |
| Está declarado lejos | La cascada vive en el DDL, escrito hace tres años por otra persona. Quien ejecuta el DELETE puede no saber que existe |
| Rendimiento imprevisible | Un CASCADE sobre una FK sin índice recorre la tabla hija entera por cada fila padre borrada (módulo 8) |
| Puede saltarse reglas de negocio | Borra sin pasar por la lógica de la aplicación: contadores, agregados y auditorías se quedan desincronizados |
Por eso muchos equipos adoptan una política más conservadora: RESTRICT en todas las FK, y borrado explícito en el orden correcto, dentro de una transacción.
BEGIN;
DELETE FROM devoluciones WHERE pedido_id = 6; -- 1) los nietos
DELETE FROM lineas_pedido WHERE pedido_id = 6; -- 2) los hijos
DELETE FROM pedidos WHERE id = 6; -- 3) el padre
COMMIT;Compara las dos salidas. Con CASCADE: DELETE 1, y cuatro filas fuera. Explícitamente: DELETE 1, DELETE 2, DELETE 1 — cuatro filas, y las ves todas. Escribes tres líneas en lugar de una y ganas visibilidad completa sobre lo que estás destruyendo. En un sistema con datos reales, ese cambio vale mucho más de lo que cuesta.
CASCADE |
RESTRICT + borrado explícito |
|
|---|---|---|
| Líneas de código | 1 | N (una por nivel) |
| Visibilidad del alcance | Ninguna | Total |
| Riesgo de olvidar un nivel | Ninguno | Existe (te lo dirá el error) |
Protección frente a un DELETE accidental |
Ninguna | La FK te frena |
| Adecuado para | Composición estricta y bien acotada | Casi todo lo demás |
TiendaVerde usa CASCADE en cuatro sitios y los cuatro son composición real: una línea de pedido, una devolución y una reseña no significan nada sin su padre. Es el uso correcto. La regla de 01-05 sigue vigente: pregúntate si la fila hija tiene sentido por sí sola, y si dudas, RESTRICT.
DELETE ... USING: borrar según otra tabla
DELETE ... USING: borrar según otra tablaIgual que UPDATE tiene FROM, DELETE tiene USING: permite decidir qué borrar en función de otra tabla.
Depuración del catálogo: eliminar las líneas de los pedidos cancelados.
BEGIN;
-- Paso 1: ver qué se va a borrar
SELECT lp.id, lp.pedido_id, pe.estado, p.nombre, lp.cantidad, lp.precio_unitario
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
JOIN productos AS p ON lp.producto_id = p.id
WHERE pe.estado = 'cancelado'
ORDER BY lp.id;| id | pedido_id | estado | nombre | cantidad | precio_unitario |
|---|---|---|---|---|---|
| 14 | 6 | cancelado | Aceite de oliva virgen extra 500 ml | 1 | 12.50 |
| 15 | 6 | cancelado | Aceite corporal de almendras 200 ml | 1 | 14.25 |
-- Paso 2: el DELETE
DELETE FROM lineas_pedido AS lp
USING pedidos AS pe
WHERE lp.pedido_id = pe.id
AND pe.estado = 'cancelado'
RETURNING lp.id, lp.pedido_id, lp.producto_id, lp.cantidad;| id | pedido_id | producto_id | cantidad |
|---|---|---|---|
| 14 | 6 | 1 | 1 |
| 15 | 6 | 8 | 1 |
| lineas |
|---|
| 45 |
Reglas de USING, todas heredadas de UPDATE ... FROM:
- Puedes poner varias tablas, separadas por comas o con
JOIN. - La tabla destino no se repite en el
USING. Si lo haces, producto cartesiano silencioso. - Al contrario que en
UPDATE ... FROM, un emparejamiento 1:N no es un problema aquí: una fila que empareja con tres se borra una sola vez.DELETEes idempotente por naturaleza.
Nota de dialecto:
USINGes de PostgreSQL. MySQL escribeDELETE lp FROM lineas_pedido lp JOIN pedidos pe ON ... WHERE ...(fíjate en el alias repetido trasDELETE). SQL Server usaDELETE d FROM destino d JOIN otra o ON .... Oracle no tiene ninguna de las dos y obliga a una subconsulta correlacionada. La forma portable es siempre una subconsulta:DELETE FROM lineas_pedido WHERE pedido_id IN (SELECT id FROM pedidos WHERE estado = 'cancelado')— módulo 7.
RETURNING: conservar lo que borras
RETURNING: conservar lo que borrasEn DELETE, RETURNING devuelve las filas tal como eran justo antes de desaparecer. Es la única forma de verlas sin haberlas consultado antes.
| id | pedido_id | motivo | fecha | importe |
|---|---|---|---|---|
| 3 | 13 | El formato no corresponde a lo esperado | 2025-10-30 | 19.80 |
Pero verlas no es conservarlas. Para eso, el patrón profesional combina INSERT ... SELECT (05-02) con el DELETE, dentro de una transacción:
BEGIN;
-- 1) Tabla de archivo (una sola vez)
CREATE TABLE devoluciones_archivo (
id INTEGER NOT NULL,
pedido_id INTEGER NOT NULL,
motivo VARCHAR(200) NOT NULL,
fecha DATE NOT NULL,
importe NUMERIC(10,2) NOT NULL,
fecha_baja DATE NOT NULL DEFAULT CURRENT_DATE,
CONSTRAINT pk_devoluciones_archivo PRIMARY KEY (id)
);
-- 2) Copiar lo que se va a borrar
INSERT INTO devoluciones_archivo (id, pedido_id, motivo, fecha, importe)
SELECT d.id, d.pedido_id, d.motivo, d.fecha, d.importe
FROM devoluciones AS d
WHERE d.importe < 20;
-- 3) Borrar
DELETE FROM devoluciones WHERE importe < 20;
COMMIT;Dentro de una transacción, o pasan las tres cosas o no pasa ninguna: es imposible que borres sin haber archivado. Fíjate en un detalle del DDL de la tabla de archivo: no lleva claves foráneas. Si las llevara, no podrías archivar una devolución cuyo pedido también se va a borrar. Las tablas de archivo son deliberadamente laxas.
RETURNINGcompleta la trilogía:INSERT ... RETURNINGte da elidgenerado,UPDATE ... RETURNINGel valor nuevo,DELETE ... RETURNINGla fila que se va. En los tres casos evita unSELECTadicional y la condición de carrera que lleva asociada.
- Borrado lógico frente a borrado físico
Y llegamos al fondo del asunto.
- Borrado físico:
DELETE FROM productos WHERE id = 13. La fila desaparece. - Borrado lógico:
UPDATE productos SET activo = FALSE WHERE id = 13. La fila sigue ahí, marcada como no vigente.
productos.activo y proveedores.activo son exactamente eso. No son un capricho del diseño: son la decisión, tomada en 01-05 y ahora explicada, de que en TiendaVerde nada que haya participado en una operación comercial se borra jamás.
| id | nombre | precio | stock | activo | fecha_alta |
|---|---|---|---|---|---|
| 20 | Cápsulas de espirulina 120 uds | 16.40 | 55 | false | 2025-06-01 |
| id | nombre | pais | activo | |
|---|---|---|---|---|
| 5 | EcoNordic Supplies | Alemania | [email protected] | false |
EcoNordic Supplies está inactivo y conserva sus cuatro productos en catálogo (10, 13, 18 y 20). Con un borrado físico, o bien la FK RESTRICT habría impedido borrarlo, o bien habrías tenido que borrar cuatro productos que aún se venden.
La comparación completa
| Aspecto | Borrado lógico (activo = FALSE) |
Borrado físico (DELETE) |
|---|---|---|
| Histórico y auditoría | Se conserva: sabes que existió y cuándo dejó de estar vigente | Se destruye |
| Integridad referencial | Intacta: los hijos siguen apuntando a algo real | Hay que decidir RESTRICT, CASCADE o SET NULL |
| Reversibilidad | Trivial: SET activo = TRUE |
Solo desde una copia de seguridad |
| Informes históricos | Siguen cuadrando | Se descuadran retroactivamente |
| Complejidad de las consultas | Todas necesitan WHERE activo |
Ninguna condición extra |
| Tamaño de las tablas | Crece indefinidamente | Se mantiene acotado |
| Rendimiento | Índices más grandes; hay que filtrar siempre | Óptimo |
| Unicidad | Se complica: ¿puede un código repetirse si el anterior está inactivo? | Trivial |
| Cumplimiento del RGPD | Problemático: el dato sigue ahí | Cumple el derecho de supresión |
El coste real: WHERE activo en todas partes
Este es el inconveniente que subestima todo el mundo:
-- Facturación por categoría del catálogo VIGENTE
SELECT cat.id,
cat.nombre AS categoria,
COUNT(*) AS productos,
ROUND(AVG(p.precio), 2) AS precio_medio
FROM productos AS p
JOIN categorias AS cat ON p.categoria_id = cat.id
WHERE p.activo
GROUP BY cat.id, cat.nombre
ORDER BY productos DESC, cat.id;| id | categoria | productos | precio_medio |
|---|---|---|---|
| 1 | Alimentación | 5 | 6.18 |
| 2 | Cosmética natural | 4 | 11.54 |
| 3 | Hogar sostenible | 4 | 10.09 |
| 4 | Bebidas | 4 | 8.90 |
| 5 | Higiene personal | 2 | 5.65 |
Cinco categorías, no seis. Complementos desaparece del informe porque su único producto (el 20) está inactivo. Y esa desaparición depende enteramente de que alguien se acordara de escribir WHERE p.activo. Olvidarlo una sola vez en un informe de dirección y estarás contando referencias que ya no se venden.
Ese olvido tiene solución, y es una vista:
-- Avance del módulo 10: no lo escribas aún
CREATE VIEW productos_vigentes AS
SELECT * FROM productos WHERE activo;A partir de ahí, las consultas de catálogo van contra productos_vigentes y el filtro es imposible de olvidar. Las vistas son la solución canónica al principal inconveniente del borrado lógico, y se estudian en la lección 10-01.
Cuándo usar cada uno
| Usa borrado lógico cuando… | Usa borrado físico cuando… |
|---|---|
| El dato ha participado en operaciones (ventas, facturas, contratos) | El dato es transitorio o de trabajo (sesiones, cachés, staging) |
| Hay obligación legal o contable de conservarlo | Es basura: pruebas, duplicados, errores de carga |
| Otras tablas lo referencian | Nadie lo referencia y nunca se referenció |
| Puede necesitar reactivarse | El volumen es un problema real de rendimiento |
| Quieres saber que existió | Existe obligación legal de suprimirlo (apartado siguiente) |
Y una tercera vía, cada vez más habitual: borrado lógico con fecha. En lugar de un booleano, una columna fecha_baja DATE nulable. Ocupa lo mismo, distingue "vigente" de "dado de baja" igual de bien (fecha_baja IS NULL) y además te dice cuándo ocurrió, que suele ser justo lo que preguntará alguien seis meses después. Si diseñas de cero, prefiérela al booleano.
- Datos personales y derecho de supresión
Hay un caso donde el borrado lógico no es suficiente: los datos personales.
El Reglamento General de Protección de Datos europeo reconoce el derecho de supresión (artículo 17, el llamado "derecho al olvido"): una persona puede exigir que sus datos personales se eliminen, y marcar una fila como inactiva no es eliminarla. El nombre, el email y la ciudad siguen en la tabla, en las copias de seguridad y en los índices.
En TiendaVerde, la tabla clientes contiene datos personales: nombre, apellidos, email y ciudad. Y su diseño ya anticipa parte del problema:
-- Qué pasa si un cliente ejerce el derecho de supresión
SELECT (SELECT COUNT(*) FROM pedidos WHERE cliente_id = 13) AS pedidos,
(SELECT COUNT(*) FROM resenas WHERE cliente_id = 13) AS resenas,
(SELECT COUNT(*) FROM clientes WHERE referido_por_id = 13) AS referidos;| pedidos | resenas | referidos |
|---|---|---|
| 0 | 0 | 0 |
Núria Bosch Ferrer (cliente 13) es uno de los tres clientes sin pedidos. Su borrado es limpio:
BEGIN;
DELETE FROM clientes WHERE id = 13
RETURNING id, nombre, apellidos, email, ciudad, pais, fecha_registro;| id | nombre | apellidos | ciudad | pais | fecha_registro | |
|---|---|---|---|---|---|---|
| 13 | Núria | Bosch Ferrer | [email protected] | Barcelona | España | 2025-06-20 |
| clientes |
|---|
| 14 |
Pero con un cliente que sí ha comprado, el conflicto aparece de inmediato:
ERROR: update or delete on table "clientes" violates foreign key constraint "pedidos_cliente_id_fkey" DETAIL: Key (id)=(5) is still referenced from table "pedidos".
Ana Belmonte Roca tiene dos pedidos (el 6 y el 18), y pedidos.cliente_id es RESTRICT. Aquí chocan dos obligaciones legítimas:
| Obligación | Qué exige |
|---|---|
| Derecho de supresión (RGPD art. 17) | Eliminar los datos personales de la persona |
| Obligación contable y fiscal | Conservar las facturas emitidas durante el plazo legal (en España, varios años) |
La solución habitual en sistemas reales no es ni borrar ni no borrar: es anonimizar. Se conserva la fila —y con ella el pedido, la factura y los totales— pero se sustituyen los datos identificativos:
-- Patrón de anonimización. Ejemplo ilustrativo: NO lo apliques
-- en un sistema real sin revisión legal.
UPDATE clientes
SET nombre = 'Cliente',
apellidos = 'anonimizado',
email = 'anon-' || id || '@invalido.local',
ciudad = NULL
WHERE id = 5;El pedido 6 sigue existiendo, la contabilidad cuadra, y el dato personal ha desaparecido. El email se construye con el id para no romper la restricción UNIQUE, y con un dominio inválido para que sea imposible enviar correo a esa dirección por accidente.
Las reseñas, en cambio, sí se borran: resenas.cliente_id está declarada ON DELETE CASCADE exactamente por este motivo, como razonaste en el ejercicio 1 de 01-05. Una reseña es una opinión personal; una factura es un documento contable.
⚖️ Aviso importante
Lo anterior es una explicación técnica de patrones habituales, no asesoramiento jurídico. Los plazos de conservación, qué se considera dato personal, qué grado de anonimización es suficiente y qué excepciones aplican dependen de la legislación vigente, del sector y del caso concreto.
Antes de implantar cualquier política de borrado o anonimización de datos personales en un sistema real, consúltalo con el responsable de protección de datos o con asesoría jurídica. Un
DELETEmal planteado puede incumplir el RGPD; uno bien intencionado puede incumplir la normativa contable. La seguridad, los permisos y el control de acceso a estos datos se estudian en la lección 11-03.
- Recuperación tras un borrado accidental
Ha pasado. Has confirmado un DELETE que no debías. ¿Qué opciones hay?
| Situación | Qué puedes hacer |
|---|---|
Aún no has hecho COMMIT |
ROLLBACK. Es la razón de todo el apartado 4 de 05-03 |
| Has confirmado, hay copia de seguridad | Restaurar el pg_dump en una base auxiliar y reinsertar solo lo que falta con INSERT ... SELECT |
| Has confirmado, hay PITR configurado | Recuperación a un punto en el tiempo: restauras la copia base y reproduces el WAL hasta el instante anterior al DELETE |
| Ninguna de las anteriores | Nada. Los datos no están |
PITR (Point-In-Time Recovery) es la técnica que permite decir "devuélveme la base tal como estaba a las 17:42:30 de ayer". Funciona porque PostgreSQL escribe todos los cambios en un registro secuencial —el WAL, Write-Ahead Log— antes de aplicarlos a los ficheros de datos. Con una copia base y el WAL posterior se puede reconstruir cualquier instante intermedio. Es también el mecanismo que hace posibles la durabilidad y la replicación, y se estudia con el resto del modelo transaccional en el módulo 9.
La conclusión práctica, que no depende de ninguna tecnología:
No hay recuperación sin copia de seguridad. La única pregunta que importa no es "¿qué hago si borro algo por error?", sino "¿cuándo se hizo la última copia y cuándo se probó por última vez restaurarla?". Una copia que nunca se ha restaurado no es una copia: es una suposición.
Para el curso, tu red de seguridad es mucho más simple: tiendaverde.sql es idempotente, y relanzarlo te devuelve al estado inicial en dos segundos.
Errores Comunes y Consejos
- Olvidar el
WHERE. Vacía la tabla entera sin aviso. Mismo error emblemático que enUPDATE, con consecuencias peores. - Fiarse del
DELETE NconCASCADE. Cuenta las filas que borras tú, no las que borra el motor en cadena.DELETE 1puede ser cuatro filas, o cuatro millones. - No comprobar qué depende de la fila antes de borrarla.
\d tablay su secciónReferenced byen cinco segundos. - Usar
TRUNCATEcreyendo que es unDELETErápido. No admiteWHERE, no dispara triggers de fila, no devuelve recuento, exige otro permiso y falla si hay FK apuntando a la tabla. - Contar con que
TRUNCATEsea transaccional. En PostgreSQL sí; en MySQL no, y ahí no hayROLLBACKposible. - Repetir la tabla destino en el
USING. Producto cartesiano silencioso, igual que enUPDATE ... FROM. - Borrar registros con histórico comercial. Para eso está
activo = FALSE. La FKRESTRICTintentará frenarte; no la esquives conCASCADE. - Implantar borrado lógico y olvidar el
WHERE activo. El informe seguirá funcionando y contará productos descatalogados. Usa una vista (módulo 10). - Creer que
activo = FALSEcumple el derecho de supresión. No lo cumple: el dato personal sigue ahí. Anonimizar o borrar, según el caso y con revisión legal. - Confundir "no tiene hijos hoy" con "se puede borrar". El producto 19 se puede borrar hoy; mañana, en cuanto alguien lo compre, ya no.
- Consejo:
SELECT *antes de cualquierDELETE. Quieres ver la fila completa que va a desaparecer, no solo suid. - Consejo:
RETURNING *siempre en unDELETE. No cuesta nada y te deja constancia de lo que has borrado, aunque sea en el historial de la consola. - Consejo: prefiere
RESTRICT+ borrado explícito aCASCADE. Tres líneas en lugar de una, a cambio de ver exactamente qué destruyes. - Consejo: al diseñar, usa
fecha_baja DATEen lugar deactivo BOOLEAN. Cuesta lo mismo y además te dice cuándo.
Ejercicios
Trabaja sobre la base recién recargada y usa BEGIN … ROLLBACK en todos los ejercicios.
Ejercicio 1
Para cada uno de estos cinco borrados, predice el resultado antes de ejecutarlo: si funciona o falla, cuántas filas se eliminan en total y en qué tablas, y qué acción ON DELETE interviene. Después compruébalo con recuentos antes y después.
-- a)
DELETE FROM proveedores WHERE id = 5;
-- b)
DELETE FROM pedidos WHERE id = 10;
-- c)
DELETE FROM categorias WHERE id = 6;
-- d)
DELETE FROM clientes WHERE id = 14;
-- e)
DELETE FROM empleados WHERE id = 1;Ejercicio 2
Dirección pide retirar del catálogo todos los productos del proveedor inactivo (EcoNordic Supplies, id 5).
- Comprueba cuáles son y cuáles de ellos se han vendido alguna vez.
- Intenta el borrado físico de todos y explica qué ocurre.
- Propón y aplica la solución correcta, justificando por qué es la correcta.
- Escribe la consulta de catálogo vigente que debería usar la web a partir de ahora, agrupada por proveedor.
Ejercicio 3
Un compañero quiere limpiar las reseñas de productos descatalogados y te enseña esta sentencia:
-- ⚠️ INCORRECTA
DELETE FROM resenas
USING productos, resenas
WHERE resenas.producto_id = productos.id
AND productos.activo = FALSE;- Encuentra el error y explica qué haría realmente.
- Escríbela correctamente y di cuántas filas borraría sobre la base recién recargada.
- Reescríbela de forma portable, sin
USING, indicando qué módulo cubre esa técnica. - Discute si esta limpieza es buena idea: ¿qué se pierde y qué se gana?
Soluciones
Solución 1
a) Falla. productos.proveedor_id es ON DELETE RESTRICT y EcoNordic tiene cuatro productos:
ERROR: update or delete on table "proveedores" violates foreign key constraint "productos_proveedor_id_fkey" on table "productos" DETAIL: Key (id)=(5) is still referenced from table "productos".
Que el proveedor esté activo = FALSE no cambia nada: el borrado lógico y la integridad referencial son mecanismos independientes.
b) Funciona, y borra 4 filas en 3 tablas. El pedido 10 tiene 2 líneas (ids 24 y 25) y 1 devolución (la 2, de 34,02 €), ambas con CASCADE:
SELECT (SELECT COUNT(*) FROM pedidos) AS pedidos,
(SELECT COUNT(*) FROM lineas_pedido) AS lineas,
(SELECT COUNT(*) FROM devoluciones) AS devoluciones;| pedidos | lineas | devoluciones |
|---|---|---|
| 20 | 47 | 3 |
| pedidos | lineas | devoluciones |
|---|---|---|
| 19 | 45 | 2 |
Y algo que no aparece en ningún recuento: la facturación total de TiendaVerde acaba de bajar de 727,95 € a 679,68 €, porque el pedido 10 aportaba 48,27 €. Los portes bajan de 118,25 € a 105,75 €. Ningún mensaje te lo ha dicho.
c) Falla, y es el ejercicio trampa. La categoría 6 (Complementos) parece vacía porque su único producto, el 20, está descatalogado. Pero productos.categoria_id es RESTRICT y esa fila sigue existiendo:
ERROR: update or delete on table "categorias" violates foreign key constraint "productos_categoria_id_fkey" on table "productos" DETAIL: Key (id)=(6) is still referenced from table "productos".
El producto 20 está activo = FALSE, pero sigue siendo una fila de productos y la clave foránea no distingue entre activo e inactivo. El borrado lógico no libera las restricciones referenciales.
d) Funciona, 1 fila. Hugo Iglesias Pardo (cliente 14) es uno de los tres sin pedidos, no tiene reseñas y no ha referido a nadie:
clientes: 15 → 14. Ninguna cascada, ningún SET NULL. Es el único de los cinco borrados verdaderamente inocuo.
e) Funciona, y desmonta el organigrama. Rosa Alcázar Vives (empleada 1) no gestiona pedidos, pero es jefa de tres personas (2, 3 y 8), y empleados.jefe_id es SET NULL:
| sin_jefe |
|---|
| 3 |
De 1 empleado sin jefe pasamos a 3. La jerarquía se ha partido en tres subárboles y la empresa se ha quedado sin dirección general en el modelo de datos. SET NULL no protesta: hace lo que se le dijo.
Resumen:
| Resultado | Filas eliminadas | Acción implicada | |
|---|---|---|---|
| a | Falla | 0 | RESTRICT (productos.proveedor_id) |
| b | Funciona | 4 en 3 tablas | CASCADE (lineas_pedido, devoluciones) |
| c | Falla | 0 | RESTRICT (productos.categoria_id) |
| d | Funciona | 1 | Ninguna: sin dependientes |
| e | Funciona | 1, más 3 modificadas | SET NULL (empleados.jefe_id) |
Solución 2
-- 1) Qué productos y cuáles se han vendido
SELECT p.id,
p.nombre,
p.precio,
p.stock,
p.activo,
COUNT(lp.id) AS veces_vendido
FROM productos AS p
LEFT JOIN lineas_pedido AS lp ON lp.producto_id = p.id
WHERE p.proveedor_id = 5
GROUP BY p.id, p.nombre, p.precio, p.stock, p.activo
ORDER BY p.id;| id | nombre | precio | stock | activo | veces_vendido |
|---|---|---|---|---|---|
| 10 | Detergente ecológico concentrado 1 L | 11.20 | 70 | true | 2 |
| 13 | Velas de cera de soja (pack 2) | 13.75 | 0 | true | 0 |
| 18 | Cepillo de dientes de bambú | 3.50 | 240 | true | 3 |
| 20 | Cápsulas de espirulina 120 uds | 16.40 | 55 | false | 0 |
Dos de los cuatro se han vendido (el 10 y el 18). El LEFT JOIN con COUNT(lp.id) es el patrón exacto de 04-05: si hubiéramos usado INNER JOIN habrían desaparecido justamente los dos que nos interesan.
ERROR: update or delete on table "productos" violates foreign key constraint "lineas_pedido_producto_id_fkey" on table "lineas_pedido" DETAIL: Key (id)=(10) is still referenced from table "lineas_pedido".
Falla, y falla entera. No se borran los dos que sí se podían borrar: un DELETE es atómico, igual que un INSERT o un UPDATE. La FK RESTRICT protege el histórico de facturación: sin ella, las líneas 11 y 39 (detergente) y 23, 32 y 47 (cepillo) se habrían quedado apuntando al vacío, y la facturación de 727,95 € dejaría de poder reconstruirse.
-- 3) La solución correcta: borrado lógico
BEGIN;
UPDATE productos
SET activo = FALSE
WHERE proveedor_id = 5
AND activo
RETURNING id, nombre, precio, stock, activo;| id | nombre | precio | stock | activo |
|---|---|---|---|---|
| 10 | Detergente ecológico concentrado 1 L | 11.20 | 70 | false |
| 13 | Velas de cera de soja (pack 2) | 13.75 | 0 | false |
| 18 | Cepillo de dientes de bambú | 3.50 | 240 | false |
SELECT COUNT(*) FILTER (WHERE activo) AS vigentes,
COUNT(*) FILTER (WHERE NOT activo) AS retirados,
COUNT(*) AS total
FROM productos;| vigentes | retirados | total |
|---|---|---|
| 16 | 4 | 20 |
Tres actualizados, no cuatro: el producto 20 ya estaba inactivo, y el filtro AND activo lo excluye. Esto hace la sentencia idempotente (05-03): reejecutarla devolvería UPDATE 0.
Por qué esta es la solución correcta, en tres puntos: conserva el histórico de facturación intacto; mantiene la integridad referencial sin necesidad de decidir cascadas; y es reversible con un SET activo = TRUE si EcoNordic vuelve a suministrar.
-- 4) Catálogo vigente por proveedor
SELECT pr.id,
pr.nombre AS proveedor,
pr.pais,
COUNT(*) AS productos,
ROUND(AVG(p.precio), 2) AS precio_medio,
SUM(p.stock) AS stock_total
FROM productos AS p
JOIN proveedores AS pr ON p.proveedor_id = pr.id
WHERE p.activo
AND pr.activo
GROUP BY pr.id, pr.nombre, pr.pais
ORDER BY productos DESC, pr.id;| id | proveedor | pais | productos | precio_medio | stock_total |
|---|---|---|---|---|---|
| 1 | Huerta del Turia | España | 5 | 5.74 | 770 |
| 3 | Verde Atlántico | Portugal | 4 | 12.91 | 280 |
| 4 | Maison Nature | Francia | 4 | 9.93 | 360 |
| 2 | BioSierra Ibérica | España | 3 | 5.27 | 410 |
Cuatro proveedores y 16 productos, frente a los cinco proveedores y 20 productos de la tabla. Fíjate en los dos filtros activo: el del producto y el del proveedor. Olvidar cualquiera de los dos devolvería un catálogo con referencias que no se pueden servir. Es exactamente el coste del borrado lógico, y exactamente lo que una vista resolvería (módulo 10).
Solución 3
1. El error. resenas aparece dos veces: como tabla destino del DELETE y dentro del USING. Es el mismo fallo que el UPDATE ... FROM del ejercicio 2 de 05-03. PostgreSQL trata la del USING como una instancia independiente, así que la condición resenas.producto_id = productos.id se resuelve contra ella y la tabla destino queda sin correlacionar. El resultado: si existe al menos una reseña de un producto inactivo, se borran todas las reseñas de la tabla. Con los datos de TiendaVerde no hay ninguna, así que borraría 0 — pero es pura suerte, y en cuanto hubiera una, se llevaría las doce.
2. La versión correcta:
-- ✅ CORRECTA
DELETE FROM resenas AS r
USING productos AS p
WHERE r.producto_id = p.id
AND NOT p.activo
RETURNING r.id, r.producto_id, r.cliente_id, r.puntuacion;Cero filas. El único producto inactivo es el 20 (Cápsulas de espirulina), y no tiene ninguna reseña — es coherente con que tampoco se haya vendido nunca. Comprobación:
SELECT p.id, p.nombre, p.activo, COUNT(r.id) AS resenas
FROM productos AS p
LEFT JOIN resenas AS r ON r.producto_id = p.id
WHERE NOT p.activo
GROUP BY p.id, p.nombre, p.activo;| id | nombre | activo | resenas |
|---|---|---|---|
| 20 | Cápsulas de espirulina 120 uds | false | 0 |
3. La versión portable, sin USING:
Funciona igual en PostgreSQL, MySQL, SQLite, SQL Server y Oracle. Las subconsultas son el módulo 7, y esta es exactamente la razón por la que se estudian: USING y FROM son extensiones propietarias, la subconsulta es estándar. Ojo con un detalle heredado de 04-02: si la subconsulta pudiera devolver algún NULL, un NOT IN daría cero filas silenciosamente. Con IN no hay problema, pero conviene tenerlo presente.
4. ¿Es buena idea?
| Se gana | Se pierde |
|---|---|
Menos filas en resenas |
El histórico de opiniones sobre el producto |
| Coherencia aparente del catálogo | La capacidad de responder "¿por qué retiramos este producto?" |
| — | La media de puntuación histórica de la categoría y del proveedor |
| — | La posibilidad de reactivar el producto conservando sus reseñas |
El veredicto es que no, casi nunca es buena idea. El borrado lógico existe para conservar información, y borrar físicamente las reseñas de un producto retirado destruye justo lo que hace útil la retirada: saber que ese producto tenía dos estrellas de media y por eso se retiró.
La alternativa correcta es no borrar nada y filtrar en la lectura: la web muestra las reseñas de productos vigentes, y los informes internos las ven todas. Un WHERE p.activo en la consulta pública resuelve el problema sin destruir un solo dato. Es la misma decisión de diseño de todo el apartado 9, aplicada un nivel más abajo.
Y una excepción legítima: si esas reseñas contuvieran datos personales de alguien que ha ejercido su derecho de supresión, sí habría que borrarlas — pero entonces el criterio sería el cliente, no el producto, y resenas.cliente_id ya está declarada ON DELETE CASCADE justo para eso.
Conclusión
DELETE cierra la trilogía del DML y plantea la pregunta que las otras dos no plantean:
- La sintaxis
DELETE FROM tabla WHERE ..., con todo el peso sobre elWHERE, y el protocolo de cinco pasos reforzado con uno nuevo: contar las filas dependientes antes de borrar. DELETEfrente aTRUNCATE: DML contra DDL,WHEREcontra todo-o-nada, lento contra instantáneo, con triggers de fila contra sin ellos, y —crítico— transaccional en PostgreSQL pero no en MySQL.TRUNCATEno ejecuta las accionesON DELETE: falla si hay FK apuntando a la tabla.- Las tres acciones
ON DELETEdemostradas con recuentos:RESTRICTimpide el borrado del producto 1 con suis still referenced from table;CASCADEconvierteDELETE FROM pedidos WHERE id = 6en cuatro filas de tres tablas informando de una sola;SET NULLdeja los cuatro pedidos de Óscar sin comercial (10 → 14 nulos) y a tres empleados sin jefe. - El peligro del
CASCADE: alcance invisible, propagación en cadena, declarado lejos de quien ejecuta. Y la alternativa profesional,RESTRICT+ borrado explícito, que cuesta tres líneas y te enseña exactamente qué destruyes. DELETE ... USINGpara borrar según otra tabla, con las mismas reglas queUPDATE ... FROM— salvo que aquí un emparejamiento 1:N no es un problema.RETURNINGpara ver la fila antes de que desaparezca, y el patrónINSERT ... SELECT+DELETEdentro de una transacción para archivarla de verdad.- Borrado lógico frente a físico:
productos.activoyproveedores.activoson borrado lógico. Conserva histórico, integridad y reversibilidad, a cambio de que todas las consultas necesitenWHERE activo— problema que resuelven las vistas del módulo 10. Y la tercera vía,fecha_baja DATE, que además te dice cuándo. - Datos personales:
activo = FALSEno cumple el derecho de supresión, la anonimización es el patrón habitual cuando choca con la obligación contable, y cualquier política de borrado de datos personales exige revisión legal. - Recuperación:
ROLLBACKsi no has confirmado, copia de seguridad o PITR si sí — y ninguna de las dos existe si nadie la preparó antes.
Ya sabes insertar, modificar y borrar por separado. Falta la operación que los sistemas reales necesitan constantemente y que ninguna de las tres resuelve: "inserta esta fila si no existe, y actualízala si ya existe". Sincronizar un catálogo con el fichero de un proveedor, registrar el stock recibido de un artículo que quizá aún no está dado de alta, guardar la puntuación de una reseña que el cliente puede haber escrito antes. En la siguiente lección, Instrucción UPSERT (MERGE), verás por qué la solución evidente —consultar y luego decidir— es incorrecta en cuanto hay dos usuarios a la vez, y las dos formas que PostgreSQL ofrece para resolverlo en una sola sentencia atómica: INSERT ... ON CONFLICT y el MERGE del estándar.
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
