Skip to content

Restricciones e integridad ​

Tu aplicación valida los formularios, ¿verdad? Pero los datos también llegan por scripts de importación, otros sistemas, un compañero que entra directo a phpMyAdmin o un bug en el código. Las restricciones (constraints) son reglas que la propia base de datos hace cumplir siempre, sin importar de dónde venga el dato. Son tu última línea de defensa contra datos basura.

Concepto ​

Las restricciones son como el reglamento de un edificio que el portero hace cumplir: no importa quién seas ni qué excusa traigas, si no cumples la regla, no pasas.

RestricciónRegla que impone
NOT NULLEl valor es obligatorio
UNIQUENo se repite en la tabla
PRIMARY KEYIdentifica la fila: UNIQUE + NOT NULL
FOREIGN KEYDebe existir en la tabla referenciada
CHECKDebe cumplir una condición
DEFAULTValor automático si no se indica (no restringe, pero ayuda)

La integridad de datos tiene tres caras:

  • De entidad: cada fila es identificable (PK).
  • Referencial: las relaciones apuntan a cosas que existen (FK).
  • De dominio: los valores son válidos para su columna (tipo, CHECK, NOT NULL).

NOT NULL y DEFAULT ​

sql
CREATE TABLE clientes (
  id INT PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL,                 -- obligatorio
  email VARCHAR(150) NOT NULL,
  ciudad VARCHAR(80),                           -- opcional (acepta NULL)
  activo BOOLEAN NOT NULL DEFAULT TRUE,         -- obligatorio con valor por defecto
  creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Buena práctica

Empieza pensando que todas las columnas son NOT NULL y quítalo solo cuando "no aplica" o "desconocido" sea un valor legítimo. Menos NULL = menos sorpresas en tus consultas.

MySQL y el modo estricto

Con el modo estricto desactivado (servidores viejos o mal configurados), MySQL convierte valores inválidos en lugar de rechazarlos: un NULL en columna NOT NULL se vuelve '' o 0 con solo un warning. Asegúrate de tener sql_mode con STRICT_TRANS_TABLES (viene por defecto en MySQL 5.7+).

UNIQUE ​

sql
-- En la columna
email VARCHAR(150) NOT NULL UNIQUE

-- Con nombre (mejores mensajes de error y más fácil de eliminar)
CONSTRAINT uq_clientes_email UNIQUE (email)

UNIQUE compuesto ​

La combinación de columnas no se repite, aunque cada una por separado sí. Ejemplo: un cliente solo puede dejar una reseña por producto.

sql
CREATE TABLE resenas (
  id INT PRIMARY KEY,
  cliente_id INT NOT NULL,
  producto_id INT NOT NULL,
  calificacion INT NOT NULL CHECK (calificacion BETWEEN 1 AND 5),
  comentario TEXT,
  CONSTRAINT uq_resena_cliente_producto UNIQUE (cliente_id, producto_id),
  FOREIGN KEY (cliente_id)  REFERENCES clientes(id),
  FOREIGN KEY (producto_id) REFERENCES productos(id)
);

INSERT INTO resenas VALUES (1, 1, 1, 5, 'Excelentes');   -- ok
INSERT INTO resenas VALUES (2, 1, 4, 4, 'Muy claro');    -- ok (otro producto)
INSERT INTO resenas VALUES (3, 1, 1, 2, 'Cambié de opinión');
-- ERROR: duplicate key value violates unique constraint "uq_resena_cliente_producto"

UNIQUE y NULL

UNIQUE permite varios NULL en casi todos los motores (porque NULL no es igual a NULL). Si una columna opcional debe ser única cuando tiene valor, UNIQUE funciona tal cual. PostgreSQL 15+ permite UNIQUE NULLS NOT DISTINCT si quieres lo contrario.

CHECK ​

sql
CREATE TABLE productos (
  id INT PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL,
  categoria_id INT NULL,
  precio DECIMAL(10,2) NOT NULL,
  precio_oferta DECIMAL(10,2),
  stock INT NOT NULL DEFAULT 0,
  CONSTRAINT chk_precio_positivo CHECK (precio >= 0),
  CONSTRAINT chk_stock_positivo  CHECK (stock >= 0),
  -- regla entre columnas: la oferta debe ser menor que el precio normal
  CONSTRAINT chk_oferta CHECK (precio_oferta IS NULL OR precio_oferta < precio),
  FOREIGN KEY (categoria_id) REFERENCES categorias(id)
);
sql
-- Restringir el estado a una lista de valores
ALTER TABLE pedidos
  ADD CONSTRAINT chk_estado
  CHECK (estado IN ('pendiente', 'pagado', 'enviado', 'entregado', 'cancelado'));

UPDATE pedidos SET estado = 'perdido' WHERE id = 1;
-- ERROR: new row for relation "pedidos" violates check constraint "chk_estado"

CHECK en MySQL antiguo

MySQL ignoraba los CHECK antes de la versión 8.0.16 (los aceptaba sin aplicarlos). Si tu hosting tiene MySQL 5.7, no confíes en ellos. Revisa tu versión con SELECT VERSION();.

Un CHECK solo puede ver la fila actual. Reglas que involucran otras filas u otras tablas ("el total de inscritos no supera el cupo del grupo") se resuelven con transacciones, triggers o en la aplicación.

FOREIGN KEY a fondo ​

sql
CONSTRAINT fk_pedidos_cliente
  FOREIGN KEY (cliente_id) REFERENCES clientes(id)
  ON DELETE RESTRICT
  ON UPDATE CASCADE

La FK impide:

  • Insertar un pedido con cliente_id = 99 si el cliente 99 no existe.
  • Borrar un cliente que tiene pedidos (según la acción ON DELETE).

Acciones referenciales: ON DELETE y ON UPDATE ​

¿Qué pasa con los "hijos" cuando se borra (o cambia la PK de) el "padre"?

AcciónAl borrar el padre...Ejemplo de uso
RESTRICT / NO ACTION (por defecto)Prohíbe el borrado si hay hijosClientes con pedidos: no se pueden borrar
CASCADEBorra también los hijosBorrar un pedido borra su detalle
SET NULLPone la FK de los hijos en NULLBorrar una categoría deja sus productos "sin categoría"
SET DEFAULTPone la FK en su valor por defectoPoco usado (MySQL/InnoDB no lo soporta)
txt
ON DELETE CASCADE                         ON DELETE SET NULL
pedidos (id 4) ──borrar──► ✘              categorias (id 3) ──borrar──► ✘
   │                                          │
   ├─ detalle (4,5) ──► ✘ borrado             ├─ producto 6 → categoria_id = NULL
   ├─ detalle (4,6) ──► ✘ borrado             └─ producto 7 → categoria_id = NULL
   └─ detalle (4,7) ──► ✘ borrado

Aplicándolo al esquema de la tienda ​

Las decisiones correctas para nuestro negocio:

sql
-- productos → categorias: si se borra la categoría, el producto queda sin categoría
ALTER TABLE productos
  ADD CONSTRAINT fk_productos_categoria
  FOREIGN KEY (categoria_id) REFERENCES categorias(id)
  ON DELETE SET NULL;

-- pedidos → clientes: NO se puede borrar un cliente con pedidos (historial contable)
ALTER TABLE pedidos
  ADD CONSTRAINT fk_pedidos_cliente
  FOREIGN KEY (cliente_id) REFERENCES clientes(id)
  ON DELETE RESTRICT;

-- detalle_pedido → pedidos: el detalle no tiene sentido sin su pedido
ALTER TABLE detalle_pedido
  ADD CONSTRAINT fk_detalle_pedido
  FOREIGN KEY (pedido_id) REFERENCES pedidos(id)
  ON DELETE CASCADE;

-- detalle_pedido → productos: no se puede borrar un producto que ya se vendió
ALTER TABLE detalle_pedido
  ADD CONSTRAINT fk_detalle_producto
  FOREIGN KEY (producto_id) REFERENCES productos(id)
  ON DELETE RESTRICT;

(Si ya existían FK sin nombre, primero elimínalas; en MySQL con SHOW CREATE TABLE ves su nombre automático y la borras con ALTER TABLE t DROP FOREIGN KEY nombre;.)

Con eso:

sql
DELETE FROM pedidos WHERE id = 5;       -- ✔ borra el pedido y su detalle (CASCADE)
DELETE FROM categorias WHERE id = 3;    -- ✔ Lámpara y Taza quedan con categoria_id NULL
DELETE FROM clientes WHERE id = 1;      -- ✘ ERROR: Ana tiene pedidos
DELETE FROM productos WHERE id = 1;     -- ✘ ERROR: los audífonos aparecen en pedidos

CASCADE con cuidado

ON DELETE CASCADE es cómodo pero peligroso en cadena: si clientes → pedidos → detalle fueran todos CASCADE, borrar un cliente por error eliminaría todo su historial de compras sin preguntar. Úsalo solo en relaciones de "parte de" (detalle de un pedido, imágenes de un producto) y prefiere RESTRICT + borrado lógico para entidades de negocio.

Buena práctica

Nombra tus restricciones con un patrón: pk_tabla, fk_tabla_referencia, uq_tabla_columna, chk_tabla_regla. Cuando tu app reciba el error violates foreign key constraint "fk_pedidos_cliente", sabrás exactamente qué pasó y podrás traducirlo a un mensaje amigable.

Agregar restricciones a tablas con datos ​

Si la tabla ya tiene datos que violan la regla, el ALTER TABLE falla. Primero busca y corrige:

sql
-- ¿Hay emails repetidos antes de poner UNIQUE?
SELECT email, COUNT(*) FROM clientes GROUP BY email HAVING COUNT(*) > 1;

-- ¿Hay pedidos que apuntan a clientes inexistentes antes de crear la FK?
SELECT pe.id, pe.cliente_id
FROM pedidos pe
LEFT JOIN clientes cl ON cl.id = pe.cliente_id
WHERE cl.id IS NULL;

En PostgreSQL puedes crear la restricción sin validar lo existente y validarla después (útil en tablas enormes):

sql
ALTER TABLE pedidos ADD CONSTRAINT chk_estado
  CHECK (estado IN ('pendiente','pagado','enviado','entregado','cancelado')) NOT VALID;
ALTER TABLE pedidos VALIDATE CONSTRAINT chk_estado;

¿Validar en la app o en la base de datos? ​

En ambas, con propósitos distintos:

Validación en la appRestricción en la BD
Mensajes amigables al usuarioGarantía absoluta, sin excepciones
Reglas de negocio complejasReglas estructurales (únicos, relaciones, rangos)
Se puede saltar (bugs, scripts, otro sistema)No se puede saltar

La app da una buena experiencia; la BD garantiza la verdad.

Ejercicios ​

  1. Agrega a clientes una restricción que impida fechas de registro futuras... ¿por qué no se puede con CHECK (fecha_registro <= CURRENT_DATE) en todos los motores? Agrega en su lugar una que impida fechas anteriores a 2020.
  2. Crea la tabla direcciones (id, cliente_id, calle, ciudad, codigo_postal de 5 caracteres, es_principal) de modo que al borrar un cliente se borren sus direcciones.
  3. Un producto no puede aparecer dos veces en la lista de deseos de un mismo cliente. Crea la tabla lista_deseos.
  4. ¿Qué pasa al ejecutar DELETE FROM categorias WHERE id = 1; con la FK ON DELETE SET NULL? ¿Y con RESTRICT?
Ver solución
  1. Muchos motores (PostgreSQL, MySQL) no permiten funciones no deterministas como CURRENT_DATE en un CHECK, porque una fila válida hoy podría "volverse" inválida mañana. Esa regla va en la app o en un trigger.
sql
ALTER TABLE clientes
  ADD CONSTRAINT chk_clientes_fecha CHECK (fecha_registro >= '2020-01-01');
sql
CREATE TABLE direcciones (
  id INT PRIMARY KEY,
  cliente_id INT NOT NULL,
  calle VARCHAR(150) NOT NULL,
  ciudad VARCHAR(80) NOT NULL,
  codigo_postal CHAR(5) NOT NULL,
  es_principal BOOLEAN NOT NULL DEFAULT FALSE,
  CONSTRAINT fk_direcciones_cliente
    FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE CASCADE
);
sql
CREATE TABLE lista_deseos (
  cliente_id INT NOT NULL,
  producto_id INT NOT NULL,
  agregado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (cliente_id, producto_id),   -- la PK compuesta impide duplicados
  FOREIGN KEY (cliente_id)  REFERENCES clientes(id)  ON DELETE CASCADE,
  FOREIGN KEY (producto_id) REFERENCES productos(id) ON DELETE CASCADE
);
  1. Con SET NULL: se borra Electrónica y los productos 1, 2 y 3 quedan con categoria_id = NULL. Con RESTRICT: el DELETE falla porque hay productos que la referencian.

Resumen ​

  • Las restricciones son reglas que la BD aplica siempre: NOT NULL, UNIQUE, PRIMARY KEY, FOREIGN KEY, CHECK.
  • UNIQUE compuesto evita combinaciones repetidas (una reseña por cliente y producto).
  • CHECK valida rangos y listas (en MySQL desde 8.0.16).
  • ON DELETE: RESTRICT para entidades de negocio, CASCADE para "partes de", SET NULL para relaciones opcionales.
  • Nombra tus restricciones y valida tanto en la app como en la BD.

Academia Web — aprende, copia y construye.