En esta página

Claves y restricciones

12 min lectura TextoCap. 4 — Diseño y modelado

Claves y restricciones

Las restricciones (constraints) son las reglas que PostgreSQL aplica automáticamente para garantizar la integridad de tus datos. Son la diferencia entre una base de datos que contiene datos confiables y una que puede tener cualquier basura. En esta lección las exploraremos todas en profundidad.

PRIMARY KEY — La identidad de cada fila

La clave primaria identifica de forma única cada fila de una tabla. Es la restricción más fundamental:

-- Clave primaria simple con SERIAL (autoincrement)
CREATE TABLE articulos (
  id SERIAL PRIMARY KEY,
  titulo VARCHAR(200) NOT NULL
);

-- Equivalente explícito de SERIAL PRIMARY KEY:
CREATE SEQUENCE articulos_id_seq;
CREATE TABLE articulos2 (
  id     INTEGER     NOT NULL DEFAULT nextval('articulos_id_seq'),
  titulo VARCHAR(200) NOT NULL,
  CONSTRAINT pk_articulos2 PRIMARY KEY (id)
);

-- BIGSERIAL para tablas con muchas filas (> 2.000 millones)
CREATE TABLE eventos_log (
  id         BIGSERIAL PRIMARY KEY,
  tipo       VARCHAR(50) NOT NULL,
  ocurrido_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Añadir clave primaria a tabla existente
ALTER TABLE mi_tabla ADD PRIMARY KEY (columna_id);

UUID como clave primaria

UUID (Universally Unique Identifier) es preferible a los enteros secuenciales en muchas situaciones:

-- UUID v4: generado aleatoriamente, globalmente único
CREATE TABLE usuarios (
  id        UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  email     VARCHAR(255) NOT NULL UNIQUE,
  nombre    VARCHAR(150) NOT NULL,
  creado_en TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

INSERT INTO usuarios (email, nombre) VALUES ('[email protected]', 'Ana García');
-- id se genera automáticamente: 'a3f5c8b2-4d17-4e8c-9a01-7b6f2c3d8e9f'

-- Ventajas de UUID:
-- 1. No expone el volumen de tu tabla (el cliente no sabe cuántos usuarios tienes)
-- 2. Se puede generar en el cliente antes del INSERT (útil en sistemas distribuidos)
-- 3. Se puede usar como referencia entre bases de datos o servicios
-- 4. No tiene límite de valores (vs SERIAL: máximo 2.147 millones)

-- Desventaja: ligeramente más lento en índices B-tree (16 bytes vs 4/8 bytes)
-- Para la mayoría de aplicaciones, la diferencia es insignificante

FOREIGN KEY — Integridad referencial

Las claves foráneas garantizan que las referencias entre tablas siempre sean válidas:

CREATE TABLE categorias (
  id     SERIAL PRIMARY KEY,
  nombre VARCHAR(100) NOT NULL UNIQUE
);

CREATE TABLE productos (
  id           SERIAL        PRIMARY KEY,
  nombre       VARCHAR(200)  NOT NULL,
  precio       NUMERIC(10,2) NOT NULL,
  categoria_id INTEGER       NOT NULL
    REFERENCES categorias(id)    -- FK básica
    ON DELETE RESTRICT           -- No permite borrar categoría con productos
    ON UPDATE CASCADE,           -- Si cambia el id de categoría, actualiza aquí también
  creado_en    TIMESTAMPTZ   NOT NULL DEFAULT NOW()
);

-- FK con nombre explícito (recomendado para mensajes de error claros)
CREATE TABLE pedidos (
  id         SERIAL  PRIMARY KEY,
  cliente_id INTEGER NOT NULL,
  CONSTRAINT fk_pedidos_cliente
    FOREIGN KEY (cliente_id)
    REFERENCES clientes(id)
    ON DELETE RESTRICT
    ON UPDATE CASCADE
);

Comportamientos ON DELETE y ON UPDATE

-- RESTRICT (predeterminado): lanza error si se intenta violar la integridad
-- No puedes borrar una categoría que tiene productos

-- CASCADE: propaga la operación a las filas relacionadas
-- Si borras un pedido, también se borran sus líneas
CREATE TABLE pedidos_lineas (
  pedido_id   INTEGER NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
  producto_id INTEGER NOT NULL REFERENCES productos(id) ON DELETE RESTRICT,
  cantidad    INTEGER NOT NULL,
  PRIMARY KEY (pedido_id, producto_id)
);

-- SET NULL: pone NULL en la FK cuando se borra el referenciado
-- Útil para relaciones opcionales
CREATE TABLE artículos (
  id          SERIAL  PRIMARY KEY,
  titulo      VARCHAR(200) NOT NULL,
  autor_id    INTEGER REFERENCES usuarios(id) ON DELETE SET NULL
  -- Si se borra el usuario, el artículo queda sin autor (autor_id = NULL)
);

-- SET DEFAULT: pone el valor DEFAULT de la columna
CREATE TABLE tareas (
  id            SERIAL  PRIMARY KEY,
  descripcion   TEXT    NOT NULL,
  asignado_a    INTEGER DEFAULT 1 REFERENCES usuarios(id) ON DELETE SET DEFAULT
  -- Si se borra el usuario asignado, se reasigna al usuario con id=1
);

-- NO ACTION: similar a RESTRICT pero se evalúa al final de la transacción
-- (permite situaciones donde la violación es temporal dentro de una transacción)

UNIQUE — Unicidad sin ser clave primaria

-- UNIQUE en una columna: no permite valores duplicados
CREATE TABLE usuarios (
  id       SERIAL PRIMARY KEY,
  email    VARCHAR(255) NOT NULL UNIQUE,   -- Un email por usuario
  username VARCHAR(50) UNIQUE              -- Un username único (puede ser NULL)
);

-- Una columna UNIQUE puede tener NULL (NULL no es igual a NULL en SQL)
-- Múltiples filas pueden tener NULL en una columna UNIQUE
INSERT INTO usuarios (email) VALUES ('[email protected]');
INSERT INTO usuarios (email) VALUES ('[email protected]');  -- ❌ Error: duplicate key
INSERT INTO usuarios (email, username) VALUES ('[email protected]', NULL);
INSERT INTO usuarios (email, username) VALUES ('[email protected]', NULL);  -- ✅ OK

-- UNIQUE compuesto: la combinación de columnas debe ser única
CREATE TABLE favoritos (
  usuario_id  INTEGER NOT NULL REFERENCES usuarios(id),
  producto_id INTEGER NOT NULL REFERENCES productos(id),
  guardado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (usuario_id, producto_id)   -- Un usuario no puede favoritar el mismo producto dos veces
);

-- Añadir restricción UNIQUE a tabla existente
ALTER TABLE productos ADD CONSTRAINT unique_sku UNIQUE (sku);

CHECK — Validación personalizada

CHECK permite definir cualquier condición booleana que los datos deben cumplir:

CREATE TABLE productos (
  id            SERIAL        PRIMARY KEY,
  nombre        VARCHAR(200)  NOT NULL,
  precio        NUMERIC(10,2) NOT NULL CHECK (precio >= 0),
  precio_oferta NUMERIC(10,2) CHECK (precio_oferta >= 0),
  stock         INTEGER       NOT NULL DEFAULT 0 CHECK (stock >= 0),
  rating        NUMERIC(2,1)  CHECK (rating >= 1.0 AND rating <= 5.0),
  descuento_pct INTEGER       CHECK (descuento_pct BETWEEN 0 AND 100),
  tipo          VARCHAR(20)   CHECK (tipo IN ('físico', 'digital', 'servicio')),
  -- Check que involucra múltiples columnas (a nivel de tabla)
  CONSTRAINT check_precio_oferta_menor
    CHECK (precio_oferta IS NULL OR precio_oferta <= precio)
);

-- Ejemplo con fechas
CREATE TABLE reservas (
  id           SERIAL      PRIMARY KEY,
  inicio       DATE        NOT NULL,
  fin          DATE        NOT NULL,
  CONSTRAINT check_fechas_validas CHECK (fin > inicio)  -- La fecha de fin debe ser posterior al inicio
);

-- Check con función (más expresivo)
CREATE TABLE usuarios (
  id    SERIAL PRIMARY KEY,
  email VARCHAR(255) NOT NULL
    CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
  -- Validación básica de formato de email con regex
);

NOT NULL — Columnas obligatorias

-- NOT NULL: el valor nunca puede ser NULL
CREATE TABLE clientes (
  id       SERIAL       PRIMARY KEY,
  nombre   VARCHAR(150) NOT NULL,     -- Obligatorio
  email    VARCHAR(255) NOT NULL,     -- Obligatorio
  telefono VARCHAR(20),               -- Opcional (puede ser NULL)
  empresa  VARCHAR(200)               -- Opcional
);

-- Añadir NOT NULL a columna existente
-- Primero debes rellenar los NULLs actuales:
UPDATE clientes SET telefono = 'Sin teléfono' WHERE telefono IS NULL;
ALTER TABLE clientes ALTER COLUMN telefono SET NOT NULL;

-- Quitar NOT NULL
ALTER TABLE clientes ALTER COLUMN telefono DROP NOT NULL;

DEFAULT — Valores predeterminados

CREATE TABLE pedidos (
  id          SERIAL        PRIMARY KEY,
  cliente_id  INTEGER       NOT NULL REFERENCES clientes(id),
  estado      VARCHAR(20)   NOT NULL DEFAULT 'pendiente',
  prioridad   INTEGER       NOT NULL DEFAULT 3 CHECK (prioridad BETWEEN 1 AND 5),
  total       NUMERIC(10,2) NOT NULL DEFAULT 0,
  notas       TEXT          DEFAULT '',
  creado_en   TIMESTAMPTZ   NOT NULL DEFAULT NOW(),
  actualizado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- DEFAULT puede ser una expresión o función
CREATE TABLE tokens (
  id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  valor       TEXT        NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
  expira_en   TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
  usado       BOOLEAN     NOT NULL DEFAULT false
);

-- Cambiar el DEFAULT de una columna existente
ALTER TABLE productos ALTER COLUMN activo SET DEFAULT true;
ALTER TABLE pedidos ALTER COLUMN estado SET DEFAULT 'nuevo';
ALTER TABLE pedidos ALTER COLUMN estado DROP DEFAULT;

Claves primarias compuestas y surrogadas vs naturales

-- Clave compuesta: la PK es la combinación de varias columnas
CREATE TABLE cursos_estudiantes (
  estudiante_id INTEGER    NOT NULL REFERENCES estudiantes(id),
  curso_id      INTEGER    NOT NULL REFERENCES cursos(id),
  matriculado_en TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  nota          NUMERIC(4,2) CHECK (nota BETWEEN 0 AND 10),
  PRIMARY KEY (estudiante_id, curso_id)  -- Clave compuesta
);

-- Clave natural: usa datos del mundo real como PK
-- Ejemplo: código ISO de país
CREATE TABLE paises (
  codigo_iso VARCHAR(2)   PRIMARY KEY,  -- 'ES', 'US', 'MX'
  nombre     VARCHAR(100) NOT NULL
);

-- Claves surrogadas vs naturales: debate clásico
-- Surrogada (id SERIAL/UUID): simple, estable, universalmente aplicable
-- Natural (ISBN, DNI, código): significativa pero puede cambiar

-- Recomendación práctica: usa claves surrogadas por defecto
-- Añade UNIQUE en las claves naturales para mantener la restricción de negocio
CREATE TABLE libros (
  id       SERIAL      PRIMARY KEY,           -- Surrogada
  isbn     VARCHAR(13) NOT NULL UNIQUE,       -- Natural (también única)
  titulo   VARCHAR(300) NOT NULL,
  autor    VARCHAR(200) NOT NULL
);

Ver y gestionar restricciones existentes

-- Listar todas las restricciones de una tabla
SELECT
  conname    AS nombre_restriccion,
  contype    AS tipo,   -- p=PK, f=FK, u=UNIQUE, c=CHECK, n=NOT NULL
  pg_get_constraintdef(oid) AS definicion
FROM pg_constraint
WHERE conrelid = 'productos'::regclass;

-- Desactivar temporalmente FK (para carga masiva de datos)
-- ¡Cuidado! Solo en entornos de carga controlada
SET session_replication_role = 'replica';  -- Desactiva triggers y FKs
-- ... hacer la carga masiva ...
SET session_replication_role = 'origin';   -- Reactivar

-- Añadir restricciones a tablas existentes
ALTER TABLE productos
  ADD CONSTRAINT check_precio_positivo CHECK (precio > 0),
  ADD CONSTRAINT unique_nombre_categoria UNIQUE (nombre, categoria_id);

-- Eliminar una restricción
ALTER TABLE productos DROP CONSTRAINT check_precio_positivo;

-- Renombrar una restricción
ALTER TABLE productos RENAME CONSTRAINT unique_sku TO uq_productos_sku;

[!warning] Añadir CHECK o NOT NULL a tablas con datos existentes puede fallar. Si tienes una tabla con millones de filas y añades CHECK (precio > 0), PostgreSQL valida TODOS los registros existentes antes de crear la restricción. Si alguno falla, la operación se cancela. Para tablas grandes, usa ADD CONSTRAINT ... NOT VALID primero (no valida datos existentes) y luego VALIDATE CONSTRAINT en un momento de baja actividad.

-- Técnica para tablas grandes: añadir restricción sin validar datos existentes
ALTER TABLE productos
  ADD CONSTRAINT check_precio_positivo CHECK (precio > 0) NOT VALID;

-- Validar en un momento de menor carga (puede tardar, pero no bloquea escrituras)
ALTER TABLE productos VALIDATE CONSTRAINT check_precio_positivo;

[!tip] Nombra siempre tus restricciones explícitamente. En lugar de dejar que PostgreSQL genere nombres como productos_precio_check, usa CONSTRAINT chk_productos_precio CHECK (precio >= 0). Los nombres descriptivos hacen que los mensajes de error sean comprensibles: "ERROR: new row violates check constraint chk_productos_precio" es mucho más útil que "ERROR: new row violates check constraint products_price1".

Las restricciones son tu red de seguridad. Con un esquema bien restringido, es casi imposible insertar datos inválidos. En la siguiente lección aprenderemos sobre índices: la herramienta clave para que las consultas en tablas con millones de filas sean instantáneas.


nextSteps:

  • slug: indices-y-rendimiento label: "Siguiente: Índices y rendimiento"