Tema
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ón | Regla que impone |
|---|---|
NOT NULL | El valor es obligatorio |
UNIQUE | No se repite en la tabla |
PRIMARY KEY | Identifica la fila: UNIQUE + NOT NULL |
FOREIGN KEY | Debe existir en la tabla referenciada |
CHECK | Debe cumplir una condición |
DEFAULT | Valor 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 CASCADELa FK impide:
- Insertar un pedido con
cliente_id = 99si 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ón | Al borrar el padre... | Ejemplo de uso |
|---|---|---|
RESTRICT / NO ACTION (por defecto) | Prohíbe el borrado si hay hijos | Clientes con pedidos: no se pueden borrar |
CASCADE | Borra también los hijos | Borrar un pedido borra su detalle |
SET NULL | Pone la FK de los hijos en NULL | Borrar una categoría deja sus productos "sin categoría" |
SET DEFAULT | Pone la FK en su valor por defecto | Poco 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) ──► ✘ borradoAplicá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 pedidosCASCADE 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 app | Restricción en la BD |
|---|---|
| Mensajes amigables al usuario | Garantía absoluta, sin excepciones |
| Reglas de negocio complejas | Reglas 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
- Agrega a
clientesuna restricción que impida fechas de registro futuras... ¿por qué no se puede conCHECK (fecha_registro <= CURRENT_DATE)en todos los motores? Agrega en su lugar una que impida fechas anteriores a 2020. - 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. - Un producto no puede aparecer dos veces en la lista de deseos de un mismo cliente. Crea la tabla
lista_deseos. - ¿Qué pasa al ejecutar
DELETE FROM categorias WHERE id = 1;con la FKON DELETE SET NULL? ¿Y conRESTRICT?
Ver solución
- Muchos motores (PostgreSQL, MySQL) no permiten funciones no deterministas como
CURRENT_DATEen unCHECK, 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
);- Con
SET NULL: se borra Electrónica y los productos 1, 2 y 3 quedan concategoria_id = NULL. ConRESTRICT: elDELETEfalla porque hay productos que la referencian.
Resumen
- Las restricciones son reglas que la BD aplica siempre:
NOT NULL,UNIQUE,PRIMARY KEY,FOREIGN KEY,CHECK. UNIQUEcompuesto evita combinaciones repetidas (una reseña por cliente y producto).CHECKvalida rangos y listas (en MySQL desde 8.0.16).ON DELETE:RESTRICTpara entidades de negocio,CASCADEpara "partes de",SET NULLpara relaciones opcionales.- Nombra tus restricciones y valida tanto en la app como en la BD.