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

DELETE es una operación destructiva e irreversible una vez confirmada. A diferencia de UPDATE, que sustituye un valor por otro, DELETE hace desaparecer la fila entera — y, con ON 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 relanzar tiendaverde.sql.
  • Trabaja siempre dentro de BEGINROLLBACK / COMMIT mientras 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

  1. La sintaxis, y el protocolo reforzado
  2. DELETE sin WHERE frente a TRUNCATE
  3. Borrar una fila referenciada: RESTRICT
  4. Borrar una fila referenciada: CASCADE
  5. Borrar una fila referenciada: SET NULL
  6. El peligro del CASCADE y la alternativa profesional
  7. DELETE ... USING: borrar según otra tabla
  8. RETURNING: conservar lo que borras
  9. Borrado lógico frente a borrado físico
  10. Datos personales y derecho de supresión
  11. Recuperación tras un borrado accidental
  12. Errores Comunes y Consejos
  13. Ejercicios
  14. Conclusión

  1. La sintaxis, y el protocolo reforzado

DELETE FROM tabla
WHERE  condición;

Solo dos piezas: qué tabla y qué filas. No hay SET, no hay valores. Toda la responsabilidad recae sobre el WHERE.

DELETE FROM productos WHERE id = 19;
DELETE 1

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

  1. DELETE sin WHERE frente a TRUNCATE

Sin WHERE, DELETE vacía la tabla:

-- ⚠️ INCORRECTA fuera de una transacción
DELETE FROM resenas;
DELETE 12

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:

TRUNCATE TABLE resenas;
TRUNCATE TABLE

Hacen lo mismo y no se parecen en nada:

DELETE FROM tabla TRUNCATE TABLE tabla
Sublenguaje DML DDL
Admite WHERE 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 (se puede hacer ROLLBACK)
Transaccional en MySQL No: confirma implícitamente
Dispara TRIGGER de fila (BEFORE/AFTER DELETE) No (solo triggers de sentencia)
Reinicia la identidad No Opcional: TRUNCATE ... RESTART IDENTITY
Devuelve el número de filas (DELETE 12) No
RETURNING 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:

TRUNCATE TABLE pedidos;
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.

TRUNCATE TABLE pedidos CASCADE;
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:

TRUNCATE TABLE pedidos, lineas_pedido, devoluciones RESTART IDENTITY;
TRUNCATE TABLE

Cuándo usar cada uno. TRUNCATE para vaciar tablas de trabajo, de staging o de pruebas, donde quieres empezar de cero y la velocidad importa. DELETE para todo lo demás, y siempre que haya un WHERE de por medio. Un DELETE 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.

  1. Borrar una fila referenciada: RESTRICT

Aquí 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
DELETE FROM productos WHERE id = 1;
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 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
DELETE 1
SELECT COUNT(*) AS productos FROM productos;
productos
19
ROLLBACK;

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.

  1. Borrar una fila referenciada: CASCADE

CASCADE 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
SELECT id, pedido_id, motivo, fecha, importe FROM devoluciones WHERE pedido_id = 6;
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 FROM pedidos WHERE id = 6;
DELETE 1

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 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
ROLLBACK;

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 CASCADE

Consejo: haz \d tabla antes de cualquier DELETE sobre una tabla que no conozcas al dedillo. La sección Referenced by te dice en cinco segundos si vas a desencadenar una cascada.

  1. Borrar una fila referenciada: SET NULL

SET 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
SELECT COUNT(*) AS pedidos_sin_empleado FROM pedidos WHERE empleado_id IS NULL;
pedidos_sin_empleado
10
DELETE FROM empleados WHERE id = 4
RETURNING id, nombre, apellidos, puesto, jefe_id, salario;
id nombre apellidos puesto jefe_id salario
4 Óscar Peris Blasco Comercial 2 28500.00
DELETE 1
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.

ROLLBACK;

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.

BEGIN;

SELECT id, nombre, apellidos, puesto, jefe_id FROM empleados WHERE jefe_id = 2 ORDER BY id;
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
DELETE FROM empleados WHERE id = 2;
DELETE 1
SELECT id, nombre || ' ' || apellidos AS empleado, puesto, jefe_id
FROM   empleados ORDER BY id;
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.

ROLLBACK;

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

  1. El peligro del CASCADE y la alternativa profesional

CASCADE 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;
BEGIN
DELETE 1
DELETE 2
DELETE 1
COMMIT

Compara las dos salidas. Con CASCADE: DELETE 1, y cuatro filas fuera. Explícitamente: DELETE 1, DELETE 2, DELETE 1cuatro 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.

  1. DELETE ... USING: borrar según otra tabla

Igual que UPDATE tiene FROM, DELETE tiene USING: permite decidir qué borrar en función de otra tabla.

DELETE FROM tabla_destino AS d
USING otra_tabla AS o
WHERE d.clave = o.clave
  AND condiciones;

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
DELETE 2
SELECT COUNT(*) AS lineas FROM lineas_pedido;
lineas
45
ROLLBACK;

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. DELETE es idempotente por naturaleza.

Nota de dialecto: USING es de PostgreSQL. MySQL escribe DELETE lp FROM lineas_pedido lp JOIN pedidos pe ON ... WHERE ... (fíjate en el alias repetido tras DELETE). SQL Server usa DELETE 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.

  1. RETURNING: conservar lo que borras

En DELETE, RETURNING devuelve las filas tal como eran justo antes de desaparecer. Es la única forma de verlas sin haberlas consultado antes.

DELETE FROM devoluciones
WHERE  importe < 20
RETURNING id, pedido_id, motivo, fecha, importe;
id pedido_id motivo fecha importe
3 13 El formato no corresponde a lo esperado 2025-10-30 19.80
DELETE 1

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;
BEGIN
CREATE TABLE
INSERT 0 1
DELETE 1
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.

RETURNING completa la trilogía: INSERT ... RETURNING te da el id generado, UPDATE ... RETURNING el valor nuevo, DELETE ... RETURNING la fila que se va. En los tres casos evita un SELECT adicional y la condición de carrera que lleva asociada.

  1. 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.

SELECT id, nombre, precio, stock, activo, fecha_alta
FROM   productos
WHERE  NOT activo;
id nombre precio stock activo fecha_alta
20 Cápsulas de espirulina 120 uds 16.40 55 false 2025-06-01
SELECT id, nombre, pais, email, activo FROM proveedores WHERE NOT activo;
id nombre pais email 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.

  1. 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 email ciudad pais fecha_registro
13 Núria Bosch Ferrer [email protected] Barcelona España 2025-06-20
DELETE 1
SELECT COUNT(*) AS clientes FROM clientes;
clientes
14
ROLLBACK;

Pero con un cliente que sí ha comprado, el conflicto aparece de inmediato:

DELETE FROM clientes WHERE id = 5;
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;
UPDATE 1

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 DELETE mal 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.

  1. 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 en UPDATE, con consecuencias peores.
  • Fiarse del DELETE N con CASCADE. Cuenta las filas que borras tú, no las que borra el motor en cadena. DELETE 1 puede ser cuatro filas, o cuatro millones.
  • No comprobar qué depende de la fila antes de borrarla. \d tabla y su sección Referenced by en cinco segundos.
  • Usar TRUNCATE creyendo que es un DELETE rápido. No admite WHERE, no dispara triggers de fila, no devuelve recuento, exige otro permiso y falla si hay FK apuntando a la tabla.
  • Contar con que TRUNCATE sea transaccional. En PostgreSQL sí; en MySQL no, y ahí no hay ROLLBACK posible.
  • Repetir la tabla destino en el USING. Producto cartesiano silencioso, igual que en UPDATE ... FROM.
  • Borrar registros con histórico comercial. Para eso está activo = FALSE. La FK RESTRICT intentará frenarte; no la esquives con CASCADE.
  • 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 = FALSE cumple 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 cualquier DELETE. Quieres ver la fila completa que va a desaparecer, no solo su id.
  • Consejo: RETURNING * siempre en un DELETE. 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 a CASCADE. Tres líneas en lugar de una, a cambio de ver exactamente qué destruyes.
  • Consejo: al diseñar, usa fecha_baja DATE en lugar de activo BOOLEAN. Cuesta lo mismo y además te dice cuándo.

Ejercicios

Trabaja sobre la base recién recargada y usa BEGINROLLBACK 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).

  1. Comprueba cuáles son y cuáles de ellos se han vendido alguna vez.
  2. Intenta el borrado físico de todos y explica qué ocurre.
  3. Propón y aplica la solución correcta, justificando por qué es la correcta.
  4. 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;
  1. Encuentra el error y explica qué haría realmente.
  2. Escríbela correctamente y di cuántas filas borraría sobre la base recién recargada.
  3. Reescríbela de forma portable, sin USING, indicando qué módulo cubre esa técnica.
  4. 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
DELETE FROM pedidos WHERE id = 10;
DELETE 1
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:

DELETE 1

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:

DELETE 1
SELECT COUNT(*) AS sin_jefe FROM empleados WHERE jefe_id IS 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.

-- 2) Intento de borrado físico
DELETE FROM productos WHERE proveedor_id = 5;
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
UPDATE 3
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
COMMIT;

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;
DELETE 0

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:

DELETE FROM resenas
WHERE producto_id IN (SELECT id FROM productos WHERE NOT activo);
DELETE 0

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 el WHERE, y el protocolo de cinco pasos reforzado con uno nuevo: contar las filas dependientes antes de borrar.
  • DELETE frente a TRUNCATE: DML contra DDL, WHERE contra todo-o-nada, lento contra instantáneo, con triggers de fila contra sin ellos, y —crítico— transaccional en PostgreSQL pero no en MySQL. TRUNCATE no ejecuta las acciones ON DELETE: falla si hay FK apuntando a la tabla.
  • Las tres acciones ON DELETE demostradas con recuentos: RESTRICT impide el borrado del producto 1 con su is still referenced from table; CASCADE convierte DELETE FROM pedidos WHERE id = 6 en cuatro filas de tres tablas informando de una sola; SET NULL deja 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 ... USING para borrar según otra tabla, con las mismas reglas que UPDATE ... FROM — salvo que aquí un emparejamiento 1:N no es un problema.
  • RETURNING para ver la fila antes de que desaparezca, y el patrón INSERT ... SELECT + DELETE dentro de una transacción para archivarla de verdad.
  • Borrado lógico frente a físico: productos.activo y proveedores.activo son borrado lógico. Conserva histórico, integridad y reversibilidad, a cambio de que todas las consultas necesiten WHERE 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 = FALSE no 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: ROLLBACK si 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

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

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

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados