Todo lo que hemos construido en este módulo —el esquema, los datos, los JOIN, los informes— se apoya en una suposición que hasta ahora hemos dado por buena: que cuando prestamos dice socio_id = 14, existe un socio 14. Si esa suposición falla, los JOIN pierden filas en silencio, los recuentos mienten y los informes de la lección anterior dejan de ser fiables sin que nadie se dé cuenta.

La integridad referencial es la garantía de que eso no ocurra. Es la tercera regla del modelo relacional, la enunciamos en la lección 02-01 y la hemos estado usando de forma implícita desde que escribimos REFERENCES en el CREATE TABLE. Ahora toca dominarla: cómo se declara, qué comprueba exactamente el gestor, qué debe pasar cuando se borra la fila de la que otras dependen, cómo detectar el daño que ya existe y —una advertencia que puede ahorrarte meses de desconcierto— por qué SQLite no protege absolutamente nada si no se lo pides.

Con esta lección cerramos el módulo 2. Al terminarla, BiblioRed no solo tendrá datos correctos: tendrá un esquema que impide activamente que dejen de serlo.

Contenido

  1. Filas huérfanas: de dónde vienen y qué rompen
  2. Declarar una clave ajena
  3. Qué comprueba el gestor y cuándo
  4. Las acciones referenciales ON DELETE y ON UPDATE
  5. Las cinco opciones, comparadas
  6. Elegir la acción correcta en BiblioRed
  7. El esquema final de BiblioRed con sus acciones referenciales
  8. Claves ajenas compuestas
  9. Restricciones diferibles
  10. SQLite: PRAGMA foreign_keys = ON
  11. Detectar y limpiar filas huérfanas
  12. ¿Validar en la aplicación o en la base de datos?
  13. Errores comunes y consejos
  14. Ejercicios
  15. Conclusión

  1. Filas huérfanas: de dónde vienen y qué rompen

Una fila huérfana es una fila cuya clave ajena apunta a algo que no existe. En la hoja de cálculo de BiblioRed que diagnosticamos en la lección 01-01 había tres formas de crearlas, y las tres ocurrían a diario:

  1. Teclear un número de socio inventado. Nadie comprobaba nada: si el bibliotecario escribía 77 en lugar de 17, la fila se guardaba tan contenta.
  2. Borrar una fila de la que dependían otras. Cuando un socio se daba de baja, alguien eliminaba su línea de la pestaña "Socios"; sus quince préstamos históricos seguían en la pestaña "Préstamos", apuntando al vacío.
  3. Renumerar. Al reordenar la hoja de socios, los números cambiaban y todas las referencias anteriores pasaban a señalar a otra persona. Este es el peor de los tres, porque no deja huella: la fila no queda huérfana, queda mal adoptada.

Qué rompe exactamente una fila huérfana:

  • Los INNER JOIN la eliminan sin avisar. El informe de préstamos por sucursal de la lección 02-05 simplemente devolvería menos de lo que hay. Y como no hay error, nadie lo investiga.
  • Los LEFT JOIN la conservan con NULL, lo que produce informes con huecos que alguien tendrá que explicar.
  • Los totales no cuadran entre sí. COUNT(*) FROM prestamos da 4.312 y la suma de los préstamos por socio da 4.298. Y a partir de ahí, la confianza en la base de datos se evapora.

La integridad referencial convierte esas tres formas de crear huérfanos en errores inmediatos, en el momento exacto en que se intentan. Ese es su valor: el problema aparece cuando se puede arreglar, no seis meses después.

  1. Declarar una clave ajena

Ya lo hicimos en la lección 02-02; ahora con detalle.

Forma de columna

CREATE TABLE socios (
    socio_id    INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    nombre      VARCHAR(60) NOT NULL,
    sucursal_id INTEGER NOT NULL REFERENCES sucursales (sucursal_id)
);

Compacta y suficiente para casos simples. El gestor genera un nombre automático para la restricción, del estilo socios_sucursal_id_fkey.

Forma de tabla, con nombre propio

CREATE TABLE socios (
    socio_id    INTEGER GENERATED BY DEFAULT AS IDENTITY,
    nombre      VARCHAR(60) NOT NULL,
    sucursal_id INTEGER NOT NULL,
    CONSTRAINT pk_socios PRIMARY KEY (socio_id),
    CONSTRAINT fk_socios_sucursal
        FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id)
);

Es la que usamos en BiblioRed, por tres razones:

  1. Es obligatoria para claves ajenas compuestas (apartado 8).
  2. Permite nombrarla. Cuando salte el error, el mensaje dirá fk_socios_sucursal, no socios_sucursal_id_fkey.
  3. Permite eliminarla y recrearla con ALTER TABLE ... DROP CONSTRAINT fk_socios_sucursal, que es justo lo que haremos en el apartado 7.

Añadirla después

ALTER TABLE socios
    ADD CONSTRAINT fk_socios_sucursal
    FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id);

Al ejecutarlo, PostgreSQL valida todas las filas existentes. Si alguna es huérfana, la operación falla entera:

ERROR:  no se pudo crear la restricción de llave foránea «fk_socios_sucursal»
DETALLE:  La llave (sucursal_id)=(99) no está presente en la tabla «sucursales».

Es exactamente lo que quieres: la restricción no se activa hasta que los datos estén limpios. El apartado 11 enseña a limpiarlos.

Requisitos de la columna referenciada

La columna a la que apunta una clave ajena debe tener clave primaria o una restricción UNIQUE. No se puede referenciar una columna cualquiera:

ALTER TABLE prestamos
    ADD CONSTRAINT fk_malo FOREIGN KEY (socio_id) REFERENCES socios (apellidos);
ERROR:  no hay restricción unique que coincida con las columnas dadas
        en la tabla referida «socios»

Y tiene sentido: si la columna referenciada pudiera repetirse, "apuntar a la fila con apellido Alsina" sería ambiguo. Por eso ejemplares.libro_id puede apuntar tanto a libros.libro_id (clave primaria) como a libros.isbn (clave alternativa UNIQUE), aunque lo primero es lo sensato.

  1. Qué comprueba el gestor y cuándo

Una clave ajena impone comprobaciones a las dos tablas, no solo a la hija. Este es el cuadro completo:

Operación Tabla Qué comprueba el gestor
INSERT en la hija prestamos Que el valor de socio_id exista en socios (o sea NULL)
UPDATE de la clave ajena en la hija prestamos Lo mismo: el nuevo valor debe existir
DELETE en la padre socios Que no queden filas hijas apuntando a la fila borrada
UPDATE de la clave primaria en la padre socios Lo mismo: que no queden hijas apuntando al valor antiguo
INSERT en la padre socios Nada: añadir un socio nunca rompe nada

Comprobémoslo sobre biblioredb. Primero, desde el lado hijo:

INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
VALUES (77, 1, '2026-08-02', '2026-08-23');
ERROR:  inserción o actualización en la tabla «prestamos» viola la llave foránea
        «fk_prestamos_socio»
DETALLE:  La llave (socio_id)=(77) no está presente en la tabla «socios».

Ahora desde el lado padre:

DELETE FROM socios WHERE socio_id = 14;
ERROR:  update or delete on table "socios" violates foreign key constraint
        "fk_prestamos_socio" on table "prestamos"
DETALLE:  La llave (socio_id)=(14) todavía es referida desde la tabla «prestamos».

Y el caso del NULL, que sí se permite:

-- libros.autor_id NO es NOT NULL, así que un libro sin autor es legal
INSERT INTO libros (libro_id, isbn, titulo, autor_id, editorial, anio_publicacion, idioma)
VALUES (340, NULL, 'Bandos municipales de 1912', NULL, 'Ayuntamiento de Vallmar', 1912, 'es');
INSERT 0 1

Una clave ajena nula no apunta a nada, y eso es válido. La regla de integridad referencial habla de valores no nulos. Si quieres que la referencia sea obligatoria, hay que añadir NOT NULL: es una decisión de diseño independiente. En BiblioRed, prestamos.socio_id es NOT NULL (no existe un préstamo sin socio) mientras que libros.autor_id no lo es (sí existen obras sin autor catalogado).

Deshacemos la inserción de prueba:

DELETE FROM libros WHERE libro_id = 340;

  1. Las acciones referenciales ON DELETE y ON UPDATE

Hasta aquí, el gestor se ha limitado a prohibir. Pero prohibir no siempre es lo que quieres. Si BiblioRed retira un título entero del catálogo, tener que borrar antes sus ejemplares uno a uno es absurdo: lo natural es que se vayan con él.

Las acciones referenciales dicen qué debe hacer el gestor con las filas hijas cuando la fila padre se borra o cambia de clave:

CONSTRAINT fk_ejemplares_libro
    FOREIGN KEY (libro_id) REFERENCES libros (libro_id)
    ON DELETE CASCADE
    ON UPDATE CASCADE

Son dos cláusulas independientes:

  • ON DELETE: qué hacer cuando se borra la fila padre.
  • ON UPDATE: qué hacer cuando cambia el valor de la clave primaria de la fila padre.

Si no escribes ninguna, se aplica NO ACTION, el comportamiento por defecto del estándar (el que hemos visto en el apartado anterior).

Un laboratorio para probarlas

Para experimentar sin tocar BiblioRed, creamos dos tablas de usar y tirar:

CREATE TABLE demo_categorias (
    categoria_id INTEGER PRIMARY KEY,
    nombre       VARCHAR(40) NOT NULL
);

CREATE TABLE demo_items (
    item_id      INTEGER PRIMARY KEY,
    nombre       VARCHAR(40) NOT NULL,
    categoria_id INTEGER,
    CONSTRAINT fk_demo FOREIGN KEY (categoria_id)
        REFERENCES demo_categorias (categoria_id) ON DELETE CASCADE
);

INSERT INTO demo_categorias VALUES (1, 'Narrativa'), (2, 'Técnica');
INSERT INTO demo_items VALUES (10, 'Item A', 1), (11, 'Item B', 1), (12, 'Item C', 2);

Estado inicial: tres ítems, dos categorías.

DELETE FROM demo_categorias WHERE categoria_id = 1;
DELETE 1
SELECT * FROM demo_items;
item_id nombre categoria_id
12 Item C 2

Los ítems A y B han desaparecido. El DELETE sobre una fila ha borrado tres filas en total, y solo la primera aparece en el mensaje. Esa es la naturaleza del CASCADE: es potente y es silencioso.

Ahora probemos SET NULL:

ALTER TABLE demo_items DROP CONSTRAINT fk_demo;
ALTER TABLE demo_items ADD CONSTRAINT fk_demo
    FOREIGN KEY (categoria_id) REFERENCES demo_categorias (categoria_id) ON DELETE SET NULL;

DELETE FROM demo_categorias WHERE categoria_id = 2;
SELECT * FROM demo_items;
item_id nombre categoria_id
12 Item C (NULL)

El ítem sobrevive; solo pierde su referencia. Limpiamos el laboratorio:

DROP TABLE demo_items;
DROP TABLE demo_categorias;

  1. Las cinco opciones, comparadas

Acción Qué hace al borrar/actualizar la fila padre Requisito Riesgo
NO ACTION Rechaza la operación si quedan hijas. Es el valor por defecto. La comprobación se hace al final de la instrucción, lo que permite que un desencadenante intermedio arregle la situación Ninguno Ninguno
RESTRICT Rechaza la operación si quedan hijas. La comprobación es inmediata y no se puede diferir Ninguno Ninguno
CASCADE Propaga: borra las filas hijas (ON DELETE) o actualiza su clave ajena (ON UPDATE) Ninguno Alto: un DELETE puede llevarse miles de filas encadenadas
SET NULL Pone la clave ajena de las hijas a NULL La columna no puede ser NOT NULL Medio: quedan filas sin referencia
SET DEFAULT Pone la clave ajena al valor DEFAULT de la columna La columna debe tener DEFAULT, y ese valor debe existir en la tabla padre Medio: si el valor por defecto no existe, la operación falla

NO ACTION y RESTRICT: la diferencia real

En el 99 % de los casos se comportan igual: ambas impiden la operación. La diferencia es cuándo se comprueba:

  • RESTRICT comprueba inmediatamente, en cuanto se ejecuta la fila del DELETE.
  • NO ACTION comprueba al final de la instrucción, y además puede diferirse hasta el COMMIT si la restricción se declaró DEFERRABLE (apartado 9).

Consecuencia práctica: RESTRICT no se puede diferir nunca. Si prevés necesitar restricciones diferibles, usa NO ACTION. En BiblioRed usaremos RESTRICT donde queremos una prohibición explícita y visible en el CREATE TABLE, porque documenta la intención mejor que dejar el hueco vacío.

SET DEFAULT: la que casi nunca se usa

-- Requiere que la columna tenga DEFAULT y que ese valor exista en la tabla padre
sucursal_id INTEGER DEFAULT 1 REFERENCES sucursales (sucursal_id) ON DELETE SET DEFAULT

La idea es "si desaparece la sucursal de este ejemplar, asígnalo a la sucursal 1". El problema es evidente: si algún día alguien borra la sucursal 1, la acción falla y el DELETE se bloquea de forma difícil de diagnosticar. Existe, hay que conocerla, y en la práctica se usa muy poco. Nota: las cláusulas DEFAULT se estudian a fondo en la lección 04-04.

  1. Elegir la acción correcta en BiblioRed

La pregunta que hay que hacerse para cada clave ajena es siempre la misma:

Si desaparece la fila padre, ¿la fila hija sigue teniendo sentido por sí sola?

  • Si no tiene sentido y no vale nada → CASCADE.
  • Si no tiene sentido pero es valiosa (histórico, contabilidad, auditoría) → RESTRICT.
  • Si tiene sentido sin la referencia → SET NULL.

Apliquémoslo a las ocho claves ajenas de BiblioRed.

prestamos.socio_idRESTRICT

Por qué borrar un socio no debe arrastrar su histórico de préstamos. Un préstamo es un hecho ocurrido: el 5 de marzo de 2026 salió un ejemplar por la puerta y volvió el 2 de abril con 1,40 € de recargo. Ese hecho no deja de haber ocurrido porque la persona se dé de baja.

Si pusiéramos CASCADE, dar de baja a un socio destruiría estadísticas históricas —préstamos por año, títulos más prestados, recaudación— que la dirección usa para decidir compras. Es destrucción de información contable disfrazada de limpieza.

Con RESTRICT, el intento de borrado falla, y eso obliga a resolver la pregunta de negocio de verdad: no se borran los socios, se marcan como inactivos (activo = FALSE, como Ramón Etxebarri). Es lo que se llama borrado lógico, y es la práctica correcta para cualquier entidad con historial.

ejemplares.libro_idCASCADE

Por qué borrar un libro sí debe arrastrar sus ejemplares. Aquí la relación es de composición: un ejemplar es una copia física de un libro. EJ-3081 no es "un objeto que casualmente está asociado a El mapa del tiempo"; es un ejemplar de El mapa del tiempo. Sin el libro, la fila no significa nada: sería un objeto sin título, sin autor y sin ISBN.

Si el título desaparece del catálogo, mantener sus quince ejemplares sería conservar basura referencial. CASCADE es la acción correcta.

Y aquí ocurre algo interesante. Intentemos borrar un libro que sí tiene préstamos:

DELETE FROM libros WHERE libro_id = 331;   -- El mapa del tiempo, 3 ejemplares, 4 préstamos

El CASCADE de ejemplares intenta borrar los ejemplares 1, 2 y 3… pero el RESTRICT de prestamos.ejemplar_id lo impide:

ERROR:  update or delete on table "ejemplares" violates foreign key constraint
        "fk_prestamos_ejemplar" on table "prestamos"

La cascada se detiene al chocar con una restricción. Es exactamente el comportamiento deseado: se puede retirar del catálogo un título que nunca se prestó, pero no uno con historial. El esquema hace cumplir una regla de negocio real sin que nadie la haya programado en ninguna aplicación.

El cuadro completo

Clave ajena ON DELETE ON UPDATE Razonamiento
socios.sucursal_idsucursales RESTRICT CASCADE No se cierra una sucursal sin reasignar antes a sus socios
libros.autor_idautores SET NULL CASCADE El libro sigue existiendo aunque se depure la ficha del autor: queda como obra sin autor catalogado, igual que "Memoria del Ensanche"
ejemplares.libro_idlibros CASCADE CASCADE Composición: el ejemplar no existe sin su obra
ejemplares.sucursal_idsucursales RESTRICT CASCADE Los ejemplares hay que trasladarlos físicamente, no borrarlos
prestamos.socio_idsocios RESTRICT CASCADE Histórico: no se destruye
prestamos.ejemplar_idejemplares RESTRICT CASCADE Histórico: no se destruye
reservas.socio_idsocios CASCADE CASCADE Una reserva es una intención futura, no un hecho contable: sin socio no significa nada
reservas.libro_idlibros CASCADE CASCADE Ídem: sin el título, la reserva es inútil

La asimetría entre prestamos (RESTRICT) y reservas (CASCADE) es el corazón del razonamiento: el préstamo es historia y la reserva es futuro. La historia se conserva; el futuro que ya no puede ocurrir se descarta.

Sobre el ON UPDATE CASCADE generalizado

Todas nuestras claves primarias son subrogadas y, por definición, nunca cambian de valor (lección 02-01). Así que ON UPDATE CASCADE no se va a activar jamás. ¿Por qué ponerlo entonces?

Es un seguro barato. Si algún día hay que renumerar identificadores durante una migración o una fusión de dos catálogos, la propagación será automática en lugar de un guion manual propenso a errores. No cuesta nada y evita un desastre improbable pero grave. En un esquema con claves naturales (donde la clave sí puede corregirse) el ON UPDATE CASCADE deja de ser un seguro y pasa a ser imprescindible.

  1. El esquema final de BiblioRed con sus acciones referenciales

Este es el entregable de la lección. Sustituimos las ocho claves ajenas que creamos en 02-02 por sus versiones con acciones referenciales. Ejecútalo sobre tu biblioredb:

-- ============================================================
--  BiblioRed - Acciones referenciales
--  Módulo 2, lección 02-06. Dialecto: PostgreSQL
-- ============================================================

-- socios → sucursales
ALTER TABLE socios DROP CONSTRAINT fk_socios_sucursal;
ALTER TABLE socios ADD CONSTRAINT fk_socios_sucursal
    FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id)
    ON DELETE RESTRICT ON UPDATE CASCADE;

-- libros → autores  (SET NULL: la obra sobrevive a la ficha del autor)
ALTER TABLE libros DROP CONSTRAINT fk_libros_autor;
ALTER TABLE libros ADD CONSTRAINT fk_libros_autor
    FOREIGN KEY (autor_id) REFERENCES autores (autor_id)
    ON DELETE SET NULL ON UPDATE CASCADE;

-- ejemplares → libros  (CASCADE: composición)
ALTER TABLE ejemplares DROP CONSTRAINT fk_ejemplares_libro;
ALTER TABLE ejemplares ADD CONSTRAINT fk_ejemplares_libro
    FOREIGN KEY (libro_id) REFERENCES libros (libro_id)
    ON DELETE CASCADE ON UPDATE CASCADE;

-- ejemplares → sucursales
ALTER TABLE ejemplares DROP CONSTRAINT fk_ejemplares_sucursal;
ALTER TABLE ejemplares ADD CONSTRAINT fk_ejemplares_sucursal
    FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id)
    ON DELETE RESTRICT ON UPDATE CASCADE;

-- prestamos → socios  (RESTRICT: el histórico no se destruye)
ALTER TABLE prestamos DROP CONSTRAINT fk_prestamos_socio;
ALTER TABLE prestamos ADD CONSTRAINT fk_prestamos_socio
    FOREIGN KEY (socio_id) REFERENCES socios (socio_id)
    ON DELETE RESTRICT ON UPDATE CASCADE;

-- prestamos → ejemplares  (RESTRICT: detiene la cascada de libros)
ALTER TABLE prestamos DROP CONSTRAINT fk_prestamos_ejemplar;
ALTER TABLE prestamos ADD CONSTRAINT fk_prestamos_ejemplar
    FOREIGN KEY (ejemplar_id) REFERENCES ejemplares (ejemplar_id)
    ON DELETE RESTRICT ON UPDATE CASCADE;

-- reservas → socios  (CASCADE: intención futura, no hecho contable)
ALTER TABLE reservas DROP CONSTRAINT fk_reservas_socio;
ALTER TABLE reservas ADD CONSTRAINT fk_reservas_socio
    FOREIGN KEY (socio_id) REFERENCES socios (socio_id)
    ON DELETE CASCADE ON UPDATE CASCADE;

-- reservas → libros
ALTER TABLE reservas DROP CONSTRAINT fk_reservas_libro;
ALTER TABLE reservas ADD CONSTRAINT fk_reservas_libro
    FOREIGN KEY (libro_id) REFERENCES libros (libro_id)
    ON DELETE CASCADE ON UPDATE CASCADE;

Comprobación:

biblioredb=> \d prestamos
Restricciones de llave foránea:
    "fk_prestamos_ejemplar" FOREIGN KEY (ejemplar_id) REFERENCES ejemplares(ejemplar_id)
        ON UPDATE CASCADE ON DELETE RESTRICT
    "fk_prestamos_socio" FOREIGN KEY (socio_id) REFERENCES socios(socio_id)
        ON UPDATE CASCADE ON DELETE RESTRICT

Y una comprobación de que las reglas se aplican de verdad:

-- El libro 338 ("Manual de jardinería urbana") tiene 2 ejemplares
-- y CERO préstamos. La cascada debería funcionar.
SELECT COUNT(*) FROM ejemplares WHERE libro_id = 338;   -- 2

-- Probamos... y deshacemos con una transacción (lección 06-01)
BEGIN;
DELETE FROM libros WHERE libro_id = 338;
SELECT COUNT(*) FROM ejemplares WHERE libro_id = 338;   -- 0: la cascada actuó
ROLLBACK;

SELECT COUNT(*) FROM ejemplares WHERE libro_id = 338;   -- 2: todo restaurado

Aquí aparece por primera vez una utilidad práctica de las transacciones: probar una operación destructiva y deshacerla. BEGIN abre la transacción, ROLLBACK la anula por completo. Es el contenido de la lección 06-01; de momento, úsalo como red de seguridad.

La versión SQLite

SQLite no admite ALTER TABLE ... ADD CONSTRAINT. Para añadir acciones referenciales hay que recrear las tablas con la definición completa. En un CREATE TABLE de SQLite se escribe igual:

CREATE TABLE prestamos (
    prestamo_id               INTEGER PRIMARY KEY,
    socio_id                  INTEGER NOT NULL
        REFERENCES socios (socio_id)     ON DELETE RESTRICT ON UPDATE CASCADE,
    ejemplar_id               INTEGER NOT NULL
        REFERENCES ejemplares (ejemplar_id) ON DELETE RESTRICT ON UPDATE CASCADE,
    fecha_prestamo            TEXT NOT NULL,
    fecha_devolucion_prevista TEXT NOT NULL,
    fecha_devolucion          TEXT,
    recargo                   NUMERIC
);

Y siempre, siempre, PRAGMA foreign_keys = ON;. Lo vemos en el apartado 10.

  1. Claves ajenas compuestas

Si la clave primaria de la tabla padre es compuesta (varias columnas), la clave ajena que la referencia también tiene que serlo, con las columnas en el mismo orden.

Imagina que BiblioRed decide catalogar la ubicación física exacta de cada ejemplar. Los estantes se numeran dentro de cada sucursal: hay un estante A-12 en Centro y otro A-12 en Norte, y no son el mismo. La clave primaria natural es entonces la pareja:

CREATE TABLE estantes (
    sucursal_id   INTEGER     NOT NULL,
    codigo_estante VARCHAR(10) NOT NULL,
    sala          VARCHAR(40),
    CONSTRAINT pk_estantes PRIMARY KEY (sucursal_id, codigo_estante),
    CONSTRAINT fk_estantes_sucursal
        FOREIGN KEY (sucursal_id) REFERENCES sucursales (sucursal_id)
        ON DELETE RESTRICT ON UPDATE CASCADE
);

Y ejemplares la referenciaría así:

ALTER TABLE ejemplares ADD COLUMN codigo_estante VARCHAR(10);

ALTER TABLE ejemplares ADD CONSTRAINT fk_ejemplares_estante
    FOREIGN KEY (sucursal_id, codigo_estante)
    REFERENCES estantes (sucursal_id, codigo_estante)
    ON DELETE SET NULL ON UPDATE CASCADE;

Tres reglas que hay que conocer:

  1. El orden importa. FOREIGN KEY (a, b) REFERENCES t (x, y) empareja a con x y b con y. Invertirlos crea una restricción distinta y probablemente absurda.
  2. La forma de tabla es obligatoria. Una restricción que afecta a dos columnas no cabe en la declaración de una sola.
  3. Cuidado con los NULL parciales. Por defecto (MATCH SIMPLE, el comportamiento estándar), si alguna de las columnas es NULL, la restricción no se comprueba en absoluto. Es decir: un ejemplar con sucursal_id = 2 y codigo_estante = NULL pasaría la validación aunque no exista tal estante. Si quieres exigir que estén las dos o ninguna, hay que escribir MATCH FULL.

Una observación de diseño: en el ejemplo anterior hay un ON DELETE SET NULL sobre una clave ajena compuesta de la que sucursal_id forma parte… y sucursal_id es NOT NULL en ejemplares. Eso hace que la acción falle en la práctica. Es un buen recordatorio de que las claves ajenas compuestas son más delicadas de lo que parecen, y una de las razones de peso a favor de las claves subrogadas simples. Estas decisiones de modelado se tratan a fondo en el módulo 4.

Estas tablas son ilustrativas: no las crees en biblioredb, no forman parte del esquema del curso.

  1. Restricciones diferibles

Por defecto, las claves ajenas se comprueban inmediatamente, al ejecutar cada instrucción. Eso plantea un problema en tres situaciones reales:

  1. Referencias circulares. Si la tabla A referencia a B y B referencia a A, no se puede insertar la primera fila de ninguna de las dos.
  2. Cargas masivas en orden arbitrario. Un volcado de datos que inserta prestamos antes que socios fallará, aunque al terminar todo sea coherente.
  3. Intercambios. Cambiar dos filas de identificador entre sí pasa por un estado intermedio inválido.

La solución del estándar es declarar la restricción diferible: sus comprobaciones se posponen hasta el COMMIT de la transacción.

ALTER TABLE prestamos DROP CONSTRAINT fk_prestamos_socio;
ALTER TABLE prestamos ADD CONSTRAINT fk_prestamos_socio
    FOREIGN KEY (socio_id) REFERENCES socios (socio_id)
    ON DELETE NO ACTION ON UPDATE CASCADE
    DEFERRABLE INITIALLY DEFERRED;

Tres modos posibles:

Declaración Comportamiento
(nada) o NOT DEFERRABLE Comprobación inmediata, siempre. El valor por defecto.
DEFERRABLE INITIALLY IMMEDIATE Inmediata por defecto, pero se puede diferir en una transacción concreta con SET CONSTRAINTS ... DEFERRED
DEFERRABLE INITIALLY DEFERRED Diferida al COMMIT por defecto

Con la restricción diferida, esto funciona:

BEGIN;
  -- Insertamos el préstamo ANTES que el socio: estado intermedio inválido
  INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
  VALUES (21, 13, '2026-08-02', '2026-08-23');

  INSERT INTO socios (socio_id, nombre, apellidos, fecha_alta, sucursal_id, activo)
  VALUES (21, 'Berta', 'Colomer', '2026-08-02', 3, TRUE);
COMMIT;   -- aquí se comprueba todo: correcto

Si al llegar al COMMIT la coherencia no se hubiera restablecido, la transacción entera se anula.

Cuatro advertencias:

  • RESTRICT nunca se puede diferir. Solo NO ACTION admite DEFERRABLE. Es la diferencia práctica entre ambas que anunciábamos en el apartado 5.
  • Las restricciones diferidas consumen más memoria, porque el gestor debe recordar todas las comprobaciones pendientes hasta el COMMIT.
  • El error aparece al confirmar, no en la instrucción culpable, lo que dificulta el diagnóstico.
  • SQLite solo admite DEFERRABLE INITIALLY DEFERRED, y únicamente si las claves ajenas están activadas.

Consejo: no las uses por defecto. Son una herramienta para casos concretos —cargas masivas, migraciones, referencias circulares—, no una comodidad general. Deshaz el experimento completo si lo has probado, para que biblioredb vuelva a su estado del apartado 7:

-- 1) Borrar los datos de prueba (primero la hija, después la padre)
DELETE FROM prestamos WHERE socio_id = 21;
DELETE FROM socios    WHERE socio_id = 21;

-- 2) Restaurar la restricción no diferible
ALTER TABLE prestamos DROP CONSTRAINT fk_prestamos_socio;
ALTER TABLE prestamos ADD CONSTRAINT fk_prestamos_socio
    FOREIGN KEY (socio_id) REFERENCES socios (socio_id)
    ON DELETE RESTRICT ON UPDATE CASCADE;

SELECT COUNT(*) FROM socios;      -- 10
SELECT COUNT(*) FROM prestamos;   -- 12

  1. SQLite: PRAGMA foreign_keys = ON

Esta es probablemente la advertencia más importante de la lección, y la más fácil de pasar por alto.

SQLite acepta la sintaxis REFERENCES, la almacena en el esquema, la muestra en .schema… y NO LA APLICA, salvo que se active explícitamente en cada conexión.

Por compatibilidad con versiones antiguas, las claves ajenas están desactivadas por defecto. El resultado es un esquema que parece protegido y no lo está:

sqlite> PRAGMA foreign_keys;
0
sqlite> INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
   ...> VALUES (77, 1, '2026-08-02', '2026-08-23');
sqlite>

Ni error, ni aviso: una fila huérfana recién creada, referida a un socio 77 que no existe. Ahora con la comprobación activada:

sqlite> PRAGMA foreign_keys = ON;
sqlite> INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
   ...> VALUES (77, 1, '2026-08-02', '2026-08-23');
Error: FOREIGN KEY constraint failed

Lo que hay que saber:

  • Es por conexión, no por base de datos. Cada vez que abres sqlite3 o cada vez que tu aplicación abre una conexión, hay que ejecutarlo de nuevo. No se guarda en el fichero.
  • No se puede activar dentro de una transacción: si lo intentas, se ignora en silencio.
  • Las bibliotecas de acceso a datos no siempre lo hacen por ti. Algunos ORM y controladores lo activan automáticamente; otros no. Compruébalo tú.
  • Ponlo como primera línea de todos tus guiones .sql.

Y una herramienta de diagnóstico específica, muy útil cuando heredas un fichero:

sqlite> PRAGMA foreign_key_check;
prestamos|13|socios|0

Cada línea es una violación existente: tabla, rowid de la fila culpable, tabla padre e índice de la clave ajena. Sin argumentos revisa toda la base de datos. Es lo primero que hay que ejecutar al recibir una base SQLite ajena.

  1. Detectar y limpiar filas huérfanas

Activar las claves ajenas impide crear huérfanos nuevos. No arregla los que ya existen: de hecho, PostgreSQL se negará a crear la restricción mientras queden. Necesitamos detectarlos y limpiarlos primero.

Reproduzcamos el escenario real: BiblioRed importa los préstamos históricos de la hoja de cálculo a una tabla intermedia sin restricciones, que es como se hacen todas las migraciones.

CREATE TABLE prestamos_import (
    fila            INTEGER,
    socio_id        INTEGER,
    codigo_ejemplar VARCHAR(10),
    fecha_prestamo  DATE
);

INSERT INTO prestamos_import (fila, socio_id, codigo_ejemplar, fecha_prestamo) VALUES
    (1, 14,   'EJ-3081', '2026-02-03'),
    (2, 77,   'EJ-3085', '2026-02-05'),   -- socio inexistente
    (3, 16,   'EJ-9999', '2026-02-08'),   -- ejemplar inexistente
    (4, 15,   'EJ-3084', '2026-02-11'),
    (5, NULL, 'EJ-3082', '2026-02-14'),   -- socio sin identificar
    (6, 77,   'EJ-3090', '2026-02-19');   -- socio inexistente, otra vez

Detección con LEFT JOIN ... IS NULL

Es el patrón anti-join de la lección 02-04, aplicado a la auditoría de datos.

-- Filas cuyo socio no existe (excluimos los NULL: son otra categoría de problema)
SELECT i.fila, i.socio_id, i.codigo_ejemplar, i.fecha_prestamo
FROM prestamos_import i
LEFT JOIN socios s ON s.socio_id = i.socio_id
WHERE i.socio_id IS NOT NULL
  AND s.socio_id IS NULL
ORDER BY i.fila;
fila socio_id codigo_ejemplar fecha_prestamo
2 77 EJ-3085 2026-02-05
6 77 EJ-3090 2026-02-19
-- Filas cuyo ejemplar no existe
SELECT i.fila, i.socio_id, i.codigo_ejemplar
FROM prestamos_import i
LEFT JOIN ejemplares e ON e.codigo = i.codigo_ejemplar
WHERE e.ejemplar_id IS NULL
ORDER BY i.fila;
fila socio_id codigo_ejemplar
3 16 EJ-9999

La misma pregunta con NOT EXISTS, que es igual de válida y algo más legible:

SELECT i.fila, i.socio_id
FROM prestamos_import i
WHERE i.socio_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM socios s WHERE s.socio_id = i.socio_id);

Un informe de auditoría completo

SELECT i.fila,
       i.socio_id,
       i.codigo_ejemplar,
       CASE WHEN i.socio_id IS NULL                THEN 'socio sin identificar'
            WHEN s.socio_id IS NULL                THEN 'socio inexistente'
            WHEN e.ejemplar_id IS NULL             THEN 'ejemplar inexistente'
            ELSE 'correcta'
       END AS diagnostico
FROM prestamos_import i
LEFT JOIN socios     s ON s.socio_id = i.socio_id
LEFT JOIN ejemplares e ON e.codigo   = i.codigo_ejemplar
ORDER BY i.fila;
fila socio_id codigo_ejemplar diagnostico
1 14 EJ-3081 correcta
2 77 EJ-3085 socio inexistente
3 16 EJ-9999 ejemplar inexistente
4 15 EJ-3084 correcta
5 (NULL) EJ-3082 socio sin identificar
6 77 EJ-3090 socio inexistente

Y el resumen para la reunión de proyecto, con lo aprendido en 02-05:

SELECT CASE WHEN i.socio_id IS NULL    THEN 'socio sin identificar'
            WHEN s.socio_id IS NULL    THEN 'socio inexistente'
            WHEN e.ejemplar_id IS NULL THEN 'ejemplar inexistente'
            ELSE 'correcta' END AS diagnostico,
       COUNT(*) AS filas
FROM prestamos_import i
LEFT JOIN socios     s ON s.socio_id = i.socio_id
LEFT JOIN ejemplares e ON e.codigo   = i.codigo_ejemplar
GROUP BY 1
ORDER BY filas DESC;
diagnostico filas
socio inexistente 2
correcta 2
ejemplar inexistente 1
socio sin identificar 1

Dos tercios de las filas tienen problemas. Ese es el dato con el que se va a hablar con la dirección.

Las cuatro estrategias de limpieza

Estrategia Cuándo Cómo
Corregir El valor correcto es deducible (77 era 17, un error de tecleo) UPDATE fila a fila, con criterio humano
Poner a NULL La columna lo admite y "desconocido" es aceptable UPDATE ... SET socio_id = NULL WHERE ...
Crear la fila padre El padre existía de verdad y se perdió en la migración INSERT en la tabla padre
Descartar La fila no se puede salvar Moverla a una tabla de cuarentena y borrarla

La regla de oro: nunca borres huérfanos sin guardarlos antes. Puede que la información que falta esté en otro sitio, y una vez borrada no vuelve.

-- 1) Cuarentena: guardamos lo que no se puede importar
CREATE TABLE prestamos_import_rechazados AS
SELECT i.*
FROM prestamos_import i
LEFT JOIN socios     s ON s.socio_id = i.socio_id
LEFT JOIN ejemplares e ON e.codigo   = i.codigo_ejemplar
WHERE i.socio_id IS NULL OR s.socio_id IS NULL OR e.ejemplar_id IS NULL;

SELECT COUNT(*) FROM prestamos_import_rechazados;   -- 4

-- 2) Importar solo lo válido a la tabla real
INSERT INTO prestamos (socio_id, ejemplar_id, fecha_prestamo, fecha_devolucion_prevista)
SELECT i.socio_id, e.ejemplar_id, i.fecha_prestamo, i.fecha_prestamo + 21
FROM prestamos_import i
INNER JOIN socios     s ON s.socio_id = i.socio_id
INNER JOIN ejemplares e ON e.codigo   = i.codigo_ejemplar;
INSERT 0 2

Fíjate en el detalle: el INSERT ... SELECT con INNER JOIN importa únicamente las filas que emparejan. Las huérfanas se quedan fuera por construcción, sin necesidad de un WHERE que filtrarlas. Es el uso deliberado de una propiedad del INNER JOIN que en otros contextos era un peligro.

Ahora limpiamos el experimento para dejar biblioredb como estaba (los dos préstamos importados tendrían los identificadores 13 y 14):

DELETE FROM prestamos WHERE prestamo_id > 12;
DROP TABLE prestamos_import_rechazados;
DROP TABLE prestamos_import;

SELECT COUNT(*) FROM prestamos;   -- 12

  1. ¿Validar en la aplicación o en la base de datos?

Es una discusión recurrente en los equipos de desarrollo, y merece una respuesta razonada.

El argumento a favor de la aplicación: los mensajes de error son mejores ("Ese socio no existe, ¿quieres darlo de alta?" en lugar de un texto en inglés sobre fk_prestamos_socio), se valida antes de llegar a la base de datos, y con un ORM las relaciones ya están descritas en el código.

El argumento a favor de la base de datos, que es el decisivo:

  1. La base de datos no es solo tuya. A lo largo de su vida, biblioredb recibirá escrituras de la aplicación web, del proceso nocturno de importación, del guion que alguien ejecuta desde psql a las once de la noche, de la herramienta de administración gráfica y de la migración de dentro de tres años. Cada uno de esos caminos tendría que reimplementar las mismas validaciones. Uno de ellos no lo hará.
  2. Los datos duran más que el código. Las aplicaciones se reescriben cada cinco o siete años; los datos se conservan décadas. Una regla que solo vive en el código desaparece con él.
  3. La concurrencia. Comprobar en la aplicación "¿existe el socio 14?" y después insertar el préstamo deja una ventana entre ambas operaciones. Si en ese intervalo otra sesión borra al socio 14, la comprobación era inútil. La base de datos verifica dentro de la operación, con los bloqueos adecuados. Este razonamiento se desarrolla en las lecciones 06-01 y 06-02.
  4. Las operaciones masivas no pasan por la aplicación. Un UPDATE de 40.000 filas se ejecuta en SQL. Ninguna validación del código lo ve.

La conclusión no es "una u otra", sino las dos, en capas:

flowchart TD
    A["Interfaz de usuario<br/>validación inmediata, mensajes claros"] --> B["Lógica de aplicación<br/>reglas de negocio complejas"]
    B --> C["Base de datos<br/>claves ajenas, UNIQUE, NOT NULL, CHECK"]
    C --> D[("Datos<br/>siempre coherentes")]
    A -.->|"puede saltarse"| C
    B -.->|"puede saltarse"| C
    style C fill:#2d6a4f,color:#ffffff

La aplicación valida para la persona: mensajes útiles, formularios que guían, errores detectados antes de enviar. La base de datos valida para los datos: es la última línea de defensa, la que nadie puede saltarse, la que sigue ahí cuando la aplicación cambie.

Un caso práctico: BiblioRed valida en el formulario web que el correo tenga formato de correo (eso la base de datos no lo comprueba bien), y la base de datos garantiza que sea UNIQUE y que el sucursal_id exista (eso la aplicación no puede garantizarlo de forma fiable). Cada capa hace lo que sabe hacer.

Nota final: las restricciones CHECK —que verifican condiciones sobre los valores, como "el estado debe ser uno de estos cuatro" o "la fecha de devolución no puede ser anterior a la de préstamo"— son la otra gran herramienta de validación en la base de datos. Se tratan a fondo en la lección 04-04, junto con DEFAULT y los criterios de elección de tipos.

Errores Comunes y Consejos

  • Olvidar PRAGMA foreign_keys = ON en SQLite. El esquema parece correcto y no protege nada. Es la trampa número uno de esta lección, y hay que repetirlo en cada conexión.
  • Poner ON DELETE CASCADE por comodidad. "Así no da errores al borrar" es la peor razón posible. Un DELETE sobre una fila puede llevarse miles encadenadas, sin aviso y sin papelera.
  • Borrar entidades con historial. Los socios, los clientes, los productos vendidos no se borran: se marcan como inactivos. RESTRICT es tu aliado precisamente porque te obliga a plantearte esa decisión.
  • Confundir RESTRICT con NO ACTION. Se comportan igual salvo en un detalle decisivo: RESTRICT no admite diferimiento.
  • Usar SET NULL sobre una columna NOT NULL. PostgreSQL rechaza la definición; en otros gestores el error aparece más tarde, al intentar el borrado.
  • Creer que una clave ajena nula es un error. No lo es: NULL significa "no apunta a nadie". Si la referencia debe ser obligatoria, añade NOT NULL.
  • Dejar las restricciones sin nombre. El día que tengas que hacer DROP CONSTRAINT, tendrás que ir a buscar el nombre automático en el catálogo.
  • Referenciar una columna sin UNIQUE. El gestor lo rechaza, y con razón: la referencia sería ambigua.
  • Borrar huérfanos sin guardarlos. Muévelos siempre a una tabla de cuarentena antes. Lo que se borra no vuelve.
  • Confiar solo en la validación de la aplicación. Habrá otro camino de escritura. Siempre lo hay.
  • Consejo: documenta la acción referencial elegida con un comentario en el CREATE TABLE. Dentro de dos años, "¿por qué esto es RESTRICT y aquello CASCADE?" será una pregunta real, y la respuesta es una decisión de negocio, no técnica.
  • Consejo: cuando heredes una base de datos, ejecuta antes que nada la auditoría de huérfanos de cada clave ajena (o PRAGMA foreign_key_check en SQLite). Te dirá en treinta segundos con qué calidad de datos estás trabajando.

Ejercicios

Ejercicio 1: Decidir la acción referencial

BiblioRed quiere añadir tres tablas nuevas. Para cada clave ajena, decide ON DELETE y justifica la elección en una frase.

  1. resenas (resena_id, socio_id, libro_id, texto, puntuacion, fecha): reseñas escritas por los socios sobre los libros.
  2. multas (multa_id, prestamo_id, importe, fecha_emision, pagada): sanciones económicas derivadas de un préstamo.
  3. eventos (evento_id, sucursal_id, titulo, fecha): actividades culturales organizadas por cada sucursal.
  4. inscripciones (inscripcion_id, evento_id, socio_id, fecha_inscripcion): socios apuntados a esos eventos.

Ejercicio 2: Predecir el efecto de un borrado

Con el esquema final del apartado 7 y el juego de datos del curso, di qué ocurre con cada instrucción y cuántas filas se ven afectadas en total.

  1. DELETE FROM autores WHERE autor_id = 8; (Marina Escolá, sin obras)
  2. DELETE FROM autores WHERE autor_id = 4; (Óscar Barreda, dos obras)
  3. DELETE FROM libros WHERE libro_id = 339; (Memoria del Ensanche, 1 ejemplar sin préstamos)
  4. DELETE FROM libros WHERE libro_id = 331; (El mapa del tiempo, 3 ejemplares, 4 préstamos, 2 reservas)
  5. DELETE FROM socios WHERE socio_id = 20; (Elena Roig, sin préstamos ni reservas)
  6. DELETE FROM socios WHERE socio_id = 16; (Nuria Bastos, 2 préstamos, 1 reserva)
  7. DELETE FROM sucursales WHERE sucursal_id = 4; (Este, 1 socio, 2 ejemplares)

Ejercicio 3: Auditoría y limpieza

BiblioRed ha recibido de otra biblioteca un fichero con socios para incorporar. Créalo como tabla intermedia:

CREATE TABLE socios_import (
    fila        INTEGER,
    nombre      VARCHAR(60),
    apellidos   VARCHAR(80),
    email       VARCHAR(120),
    sucursal_id INTEGER
);

INSERT INTO socios_import (fila, nombre, apellidos, email, sucursal_id) VALUES
    (1, 'Rosa',   'Cabanes', '[email protected]',   2),
    (2, 'Teo',    'Ninot',   '[email protected]',      9),
    (3, 'Amina',  'Bakri',   '[email protected]',    1),
    (4, 'Lluc',   'Ferrer',  '[email protected]',   3),
    (5, 'Selma',  'Duarte',  NULL,                         7),
    (6, 'Jordi',  'Pons',    '[email protected]',     NULL);
  1. Escribe una consulta que detecte las filas cuya sucursal_id no existe.
  2. Escribe una consulta que detecte las filas cuyo email ya está en uso por un socio actual.
  3. Escribe un informe de auditoría con una columna diagnostico que clasifique cada fila.
  4. Importa únicamente las filas válidas y comprueba cuántas entraron. Después, deja biblioredb como estaba.

Soluciones

Solución 1

Tabla Clave ajena ON DELETE Justificación
resenas socio_idsocios SET NULL La reseña tiene valor para los demás lectores aunque su autor se dé de baja: pasa a ser anónima. (CASCADE sería defendible si la política de privacidad exigiera borrar todo rastro del socio; es una decisión legal, no técnica.)
resenas libro_idlibros CASCADE Una reseña de un libro que ya no está en el catálogo no tiene lector posible.
multas prestamo_idprestamos RESTRICT Es un registro económico. Además, prestamos.socio_id ya es RESTRICT, así que la protección es coherente en toda la cadena.
eventos sucursal_idsucursales RESTRICT (o SET NULL) Los eventos pasados son historial de actividad; borrarlos al cerrar una sucursal destruiría las estadísticas anuales.
inscripciones evento_ideventos CASCADE Sin evento, la inscripción no significa nada.
inscripciones socio_idsocios CASCADE Igual que las reservas: es una intención futura, no un hecho contable.

El patrón que emerge: hechos económicos e históricos → RESTRICT; intenciones y elementos accesorios → CASCADE; contenido con valor propio → SET NULL.

Solución 2

# Qué ocurre Filas afectadas
1 Éxito. Marina Escolá no tiene obras, así que el SET NULL no toca nada. 1 (el autor)
2 Éxito con SET NULL. Los libros 334 y 338 sobreviven con autor_id = NULL; sus ejemplares y préstamos quedan intactos. 3 (1 autor + 2 libros modificados)
3 Éxito con CASCADE. Se borra el libro y, en cascada, su ejemplar EJ-3095, que no tiene préstamos. No hay reservas del 339. 2 (1 libro + 1 ejemplar)
4 ERROR. La cascada intenta borrar los ejemplares 1, 2 y 3, pero el RESTRICT de fk_prestamos_ejemplar lo impide: hay 4 préstamos apuntando a ellos. Nada se borra. 0
5 Éxito. Elena Roig no tiene nada asociado. 1
6 ERROR. El RESTRICT de fk_prestamos_socio bloquea el borrado por sus 2 préstamos. La reserva se habría borrado en cascada, pero la operación entera se anula. 0
7 ERROR. El RESTRICT de fk_socios_sucursal (Lucía Vendrell está dada de alta en Este) y el de fk_ejemplares_sucursal (2 ejemplares) lo impiden. Hay que reasignar antes socios y ejemplares. 0

Observación importante sobre los casos 4, 6 y 7: el fallo es atómico. Aunque la cascada hubiera empezado a borrar filas antes de chocar con el RESTRICT, todo se deshace: la instrucción es una unidad. Eso lo garantizan las transacciones, tema de la lección 06-01.

Solución 3

-- 1  Sucursal inexistente (excluyendo los NULL, que son otro caso)
SELECT i.fila, i.apellidos, i.sucursal_id
FROM socios_import i
LEFT JOIN sucursales su ON su.sucursal_id = i.sucursal_id
WHERE i.sucursal_id IS NOT NULL
  AND su.sucursal_id IS NULL
ORDER BY i.fila;
fila apellidos sucursal_id
2 Ninot 9
5 Duarte 7
-- 2  Correo ya en uso: violaría uq_socios_email
SELECT i.fila, i.apellidos, i.email
FROM socios_import i
INNER JOIN socios s ON s.email = i.email
ORDER BY i.fila;
fila apellidos email
4 Ferrer [email protected]
-- 3  Informe de auditoría
SELECT i.fila,
       i.nombre || ' ' || i.apellidos AS socio,
       CASE WHEN i.sucursal_id IS NULL    THEN 'sin sucursal asignada'
            WHEN su.sucursal_id IS NULL   THEN 'sucursal inexistente'
            WHEN s.socio_id IS NOT NULL   THEN 'correo duplicado'
            ELSE 'correcta'
       END AS diagnostico
FROM socios_import i
LEFT JOIN sucursales su ON su.sucursal_id = i.sucursal_id
LEFT JOIN socios s      ON s.email        = i.email
ORDER BY i.fila;
fila socio diagnostico
1 Rosa Cabanes correcta
2 Teo Ninot sucursal inexistente
3 Amina Bakri correcta
4 Lluc Ferrer correo duplicado
5 Selma Duarte sucursal inexistente
6 Jordi Pons sin sucursal asignada

Nota: la fila 6 no viola ninguna clave ajena —NULL es una referencia válida— pero sí violaría el NOT NULL de socios.sucursal_id. Son dos restricciones distintas y ambas hay que auditarlas.

-- 4  Importar solo lo válido
INSERT INTO socios (nombre, apellidos, email, fecha_alta, sucursal_id, activo)
SELECT i.nombre, i.apellidos, i.email, '2026-08-02', i.sucursal_id, TRUE
FROM socios_import i
INNER JOIN sucursales su ON su.sucursal_id = i.sucursal_id
WHERE NOT EXISTS (SELECT 1 FROM socios s WHERE s.email = i.email);
INSERT 0 2

Entran Rosa Cabanes y Amina Bakri. El INNER JOIN con sucursales descarta por construcción las filas 2, 5 y 6, y el NOT EXISTS descarta la 4.

-- Comprobación y limpieza
SELECT COUNT(*) FROM socios;   -- 12

DELETE FROM socios WHERE apellidos IN ('Cabanes', 'Bakri');
DROP TABLE socios_import;

SELECT COUNT(*) FROM socios;   -- 10

Conclusión

Cerramos el módulo con las garantías que hacen que todo lo anterior siga siendo cierto con el paso del tiempo:

  • Una fila huérfana es una clave ajena que apunta a algo inexistente. Nacía a diario en la hoja de cálculo de BiblioRed por tecleo, por borrados y por renumeraciones, y su daño es silencioso: los INNER JOIN la eliminan sin avisar y los totales dejan de cuadrar.
  • Una clave ajena se declara en forma de columna o, mejor, de tabla con nombre propio (CONSTRAINT fk_...), y solo puede referenciar columnas con PRIMARY KEY o UNIQUE. Al añadirla con ALTER TABLE, el gestor valida todas las filas existentes.
  • El gestor comprueba en el INSERT y el UPDATE de la tabla hija, y en el DELETE y el UPDATE de la clave primaria de la tabla padre. Una clave ajena nula es legal: si la referencia debe ser obligatoria, hay que añadir NOT NULL.
  • Las cinco acciones referencialesNO ACTION, RESTRICT, CASCADE, SET NULL y SET DEFAULT— definen qué ocurre con las hijas cuando desaparece el padre. RESTRICT no admite diferimiento; esa es su única diferencia real con NO ACTION.
  • El criterio de elección en BiblioRed: el préstamo es historia (RESTRICT) y la reserva es futuro (CASCADE); el ejemplar es una parte del libro (CASCADE) y el libro sobrevive a la ficha de su autor (SET NULL). Un socio no se borra: se marca como inactivo.
  • Las claves ajenas compuestas exigen la forma de tabla, respetan el orden de las columnas y, con MATCH SIMPLE, no se comprueban si alguna columna es nula.
  • Las restricciones diferibles (DEFERRABLE INITIALLY DEFERRED) posponen la comprobación al COMMIT, y sirven para referencias circulares, cargas masivas e intercambios. No son una comodidad general.
  • SQLite no aplica las claves ajenas si no se activa PRAGMA foreign_keys = ON, en cada conexión. PRAGMA foreign_key_check audita una base entera.
  • Los huérfanos que ya existen se detectan con el patrón LEFT JOIN ... IS NULL (o NOT EXISTS), se clasifican con CASE WHEN, se guardan en cuarentena y solo entonces se descartan. Un INSERT ... SELECT con INNER JOIN importa por construcción únicamente lo válido.
  • Y la conclusión de fondo: se valida en las dos capas. La aplicación valida para la persona; la base de datos es la última línea de defensa, porque habrá otros caminos de escritura, porque los datos duran más que el código y porque solo el gestor puede verificar dentro de la operación, sin ventanas de concurrencia.

Con esto termina el módulo 2. Has recorrido el camino completo del mundo relacional: la teoría del modelo y sus reglas de integridad, el lenguaje SQL y la creación del esquema, el CRUD sobre una tabla, la reunión de varias tablas con JOIN y subconsultas, el resumen con agregados y agrupaciones, y las garantías referenciales que lo sostienen. biblioredb ya no es una base vacía: es un sistema de información con siete tablas, datos coherentes, informes de gestión y defensas propias. En el módulo 3, Bases de Datos No Relacionales, cambiamos de mundo: veremos qué es NoSQL, qué familias existen, cómo se modelan los datos cuando no hay esquema fijo ni claves ajenas que los protejan —y qué se gana y qué se pierde en ese trato—. BiblioRed viene con nosotros: sus reseñas y su registro de actividad son, como decidimos en la lección 01-02, el caso de uso perfecto para MongoDB.

Fundamentos de Bases de Datos

Módulo 1: Introducción a las Bases de Datos

Módulo 2: Bases de Datos Relacionales

Módulo 3: Bases de Datos No Relacionales

Módulo 4: Diseño de Esquemas

Módulo 5: Normalización

Módulo 6: Transacciones, Rendimiento y Seguridad

Módulo 7: Ejercicios Prácticos

Módulo 8: Casos de Estudio

Módulo 9: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados