PostgreSQL
Constraints e integridad de datos
Uso de NOT NULL, CHECK, UNIQUE, PRIMARY KEY, FOREIGN KEY y exclusion constraints para convertir reglas del dominio en garantías de base de datos.
- Última actualización
- Actualizada
- Nivel
- Fundamentos
PostgreSQL
Uso de NOT NULL, CHECK, UNIQUE, PRIMARY KEY, FOREIGN KEY y exclusion constraints para convertir reglas del dominio en garantías de base de datos.
La integridad no consiste en validar una vez en el frontend. Consiste en impedir que cualquier camino de escritura —API, job, import, script o usuario administrativo— deje la base en un estado que contradiga el dominio.
Los constraints convierten reglas locales del modelo en garantías ejecutadas por PostgreSQL. La aplicación puede validar antes para ofrecer mejores mensajes, pero la base debe proteger aquello que puede reconocer por sí misma.
Modelo mental:
frontend
→ mejora experiencia
backend
→ coordina reglas y permisos
PostgreSQL
→ impide estados inválidos en el dato compartidoLa defensa en profundidad importa porque múltiples procesos pueden escribir concurrentemente. Una comprobación realizada antes del INSERT puede quedar obsoleta cuando otra transacción modifica el mismo estado.
Sin constraints aparecen datos imposibles:
La aplicación puede intentar evitarlo, pero solo PostgreSQL observa todas las escrituras que llegan a esa base y puede serializar conflictos mediante locks e índices.
name text NOT NULLImpide ausencia de valor.
No valida string vacío ni whitespace. Si el dominio exige contenido:
name text NOT NULL CHECK (btrim(name) <> '')Aun así, decidir si normalizar o rechazar espacios puede pertenecer al contrato de aplicación.
quantity numeric(14,3) NOT NULL
CHECK (quantity >= 0)Evalúa una expresión sobre la fila. Solo rechaza FALSE; TRUE y UNKNOWN pasan. Por eso una columna nullable necesita NOT NULL si NULL no es válido.
Puede relacionar columnas de la misma fila:
CHECK (reserved_quantity >= 0 AND reserved_quantity <= quantity)No debe consultar otras filas de forma ordinaria. PostgreSQL asume que una CHECK es estable respecto a la fila; usar funciones que leen tablas puede producir dumps o restauraciones inconsistentes.
UNIQUE (business_id, sku)Protege una candidate key. PostgreSQL crea un índice único para hacer cumplir la regla.
Por defecto, varios NULL se consideran distintos. En versiones modernas:
UNIQUE NULLS NOT DISTINCT (business_id, external_code)puede tratar NULL como valor repetible solo una vez dentro de la combinación.
PRIMARY KEY (branch_id, product_id)Combina unicidad y NOT NULL, y comunica la identidad principal. Una tabla puede tener varias candidate keys, pero una sola primary key.
customer_id bigint NOT NULL
REFERENCES customers(id)Exige que el valor exista en una key única de la tabla referenciada.
La FK protege referencia, no ownership completo. En multi-tenancy, una FK solo por customer_id puede permitir enlazar un customer de otro tenant si el ID es globalmente válido.
Diseño más seguro:
FOREIGN KEY (tenant_id, customer_id)
REFERENCES customers(tenant_id, id)Permite impedir combinaciones según operadores:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE reservations (
room_id bigint NOT NULL,
reserved_during tstzrange NOT NULL,
EXCLUDE USING gist (
room_id WITH =,
reserved_during WITH &&
)
);La constraint impide dos ranges que se solapan para el mismo room. Es una garantía de concurrencia que una comprobación previa con SELECT no puede asegurar por sí sola.
status text DEFAULT 'pending'El default se usa cuando el insert omite la columna. Esto sigue siendo válido:
INSERT INTO orders(status) VALUES ('anything');Para limitar estados:
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'cancelled'))Incluso así, una lista de estados no protege transiciones. Pasar de cancelled a pending puede requerir lógica de aplicación o trigger bien diseñado.
CREATE TABLE order_items (
order_id bigint NOT NULL,
product_id bigint NOT NULL,
quantity numeric(12,3) NOT NULL CHECK (quantity > 0),
PRIMARY KEY (order_id, product_id),
CONSTRAINT order_items_order_fk
FOREIGN KEY (order_id)
REFERENCES orders(id)
ON DELETE CASCADE,
CONSTRAINT order_items_product_fk
FOREIGN KEY (product_id)
REFERENCES products(id)
ON DELETE RESTRICT
);Interpretación:
CASCADE y RESTRICT expresan ownership diferente. No deben elegirse por comodidad.
Default. Permite que la validación ocurra en el momento correspondiente y puede ser deferrable.
Rechaza inmediatamente la operación referenciada y no es deferrable de la misma manera.
Propaga delete o update.
Útil cuando la fila hija no tiene significado independiente, como items de un draft desechable. Peligroso en auditoría, pagos o historial.
Conserva la fila hija y elimina referencia. La columna debe aceptar NULL y el dominio debe tolerarlo.
Aplica el default; ese valor todavía debe satisfacer la FK.
Una FK no crea automáticamente un índice en la columna hija.
Ejemplo:
orders.customer_id REFERENCES customers(id)Sin índice sobre orders.customer_id:
Índice:
CREATE INDEX orders_customer_id_idx
ON orders(customer_id);No indexes todas las FKs mecánicamente. Evalúa volumen, consultas y operaciones del padre. En tablas pequeñas o append-only, el coste puede no justificarse.
CONSTRAINT transfers_source_fk
FOREIGN KEY (source_id)
REFERENCES accounts(id)
DEFERRABLE INITIALLY DEFERREDUna constraint deferrable puede validarse al commit:
SET CONSTRAINTS ALL DEFERRED;Casos útiles:
Costes:
Para una tabla grande:
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id)
REFERENCES customers(id)
NOT VALID;Efecto:
Después:
ALTER TABLE orders
VALIDATE CONSTRAINT orders_customer_fk;La validación necesita recursos y locks, pero suele reducir el impacto respecto a añadir y validar todo en una única operación. Prueba en la versión desplegada.
Requisito: un customer puede tener un solo cart activo, pero muchos carts cerrados.
CREATE UNIQUE INDEX one_active_cart_per_customer
ON carts(customer_id)
WHERE status = 'active';No es una constraint declarada con ALTER TABLE, pero implementa unicidad sobre un subconjunto.
El predicado de la query debe ser compatible para que el planner utilice el índice.
CREATE TABLE branches (
tenant_id bigint NOT NULL,
id bigint NOT NULL,
name text NOT NULL,
PRIMARY KEY (tenant_id, id)
);
CREATE TABLE orders (
tenant_id bigint NOT NULL,
id bigint NOT NULL,
branch_id bigint NOT NULL,
PRIMARY KEY (tenant_id, id),
FOREIGN KEY (tenant_id, branch_id)
REFERENCES branches(tenant_id, id)
);Esto impide que una order del tenant A apunte a una branch del tenant B.
Una simple FK branch_id → branches.id no expresa el aislamiento si id no es globalmente único o si la regla debe proteger tenant explícitamente.
Requisito: confirmed_at debe existir solo para estados confirmados o posteriores.
CHECK (
(status = 'pending' AND confirmed_at IS NULL)
OR
(status IN ('confirmed', 'completed') AND confirmed_at IS NOT NULL)
)Conforme crece el state machine, un CHECK puede volverse difícil de mantener. Evalúa:
No fuerces toda la lógica de negocio dentro de una expresión ilegible.
Un constraint trigger puede validarse de forma diferida y considerar varias filas. Es una herramienta avanzada.
Úsalo cuando:
No lo uses cuando un UNIQUE, FK, CHECK o EXCLUDE resuelve el problema mejor.
PostgreSQL devuelve SQLSTATE y metadata como nombre de constraint.
El backend debería mapear por código/nombre estable, no por texto completo:
23505 unique_violation
23503 foreign_key_violation
23514 check_violation
23502 not_null_violation
23P01 exclusion_violationEjemplo conceptual:
if (error.code === '23505' && error.constraint === 'products_business_sku_key') {
throw new ProductSkuConflict();
}No conviertas toda violación en 400 genérico; el contrato puede distinguir conflicto 409, referencia inválida o error interno de migración.
Patrón inseguro:
SELECT si existe SKU
→ no existe
→ INSERTDos transacciones pueden ver “no existe” y ambas insertar.
La UNIQUE (business_id, sku) serializa el conflicto. La aplicación intenta el insert y maneja unique_violation.
La constraint no es solo validación: es mecanismo de concurrencia.
Un CHECK (expires_at > now()) es incorrecto como garantía durable: una fila válida hoy puede ser inválida mañana sin cambiar. Los CHECK deben describir una propiedad estable de la fila.
UNIQUE(email) impide reutilizar email de filas soft-deleted. Puede necesitar índice parcial:
CREATE UNIQUE INDEX users_active_email_uidx
ON users(lower(email))
WHERE deleted_at IS NULL;Desactivar constraints facilita carga, pero permite corrupción. Prefiere staging tables, validación y carga controlada.
Eliminar un parent puede borrar millones de filas y generar WAL/locks. Evalúa batch deletion y retention.
Pueden requerir nullable temporal, deferrable constraints o rediseño de ownership.
Otros writers omiten la validación y la concurrencia rompe comprobaciones previas.
Pierde integridad sin medir el coste real. Diseña índices y lifecycle.
Aumenta comportamiento oculto. Prioriza constraints declarativas.
Puede destruir historial o grandes volúmenes.
Dificulta migraciones y mapeo de errores. Usa nombres estables.
El default no limita valores explícitos.
La duplicación puede ser deliberada: aplicación para UX y base para garantía.
SELECT antes de INSERT no reemplaza UNIQUE?now()?Claves e identificadores compara identidad natural, surrogate, compuesta, sequences y UUID según integridad, exposición y rendimiento.