Llevas cinco módulos leyendo el esquema de TiendaVerde sin haber escrito ni una línea de él. Sabes que clientes.email es UNIQUE, que lineas_pedido.pedido_id es ON DELETE CASCADE, que puntuacion está protegida con un CHECK de 1 a 5 y que todas las claves primarias son id INTEGER GENERATED BY DEFAULT AS IDENTITY. Lo sabes porque lo has leído en la lección 01-06 y porque los errores de PostgreSQL te lo han recordado más de una vez. Lo que no sabes todavía es cómo se declara todo eso.

Aquí empieza el otro lado del lenguaje: el DDL, el sublenguaje de definición de datos del que hablaba 01-01. En esta lección aprenderás la sintaxis completa de CREATE TABLE —columnas, tipos y las seis restricciones, una a una—, por qué conviene ponerles nombre, cómo funcionan de verdad las columnas de identidad y en qué se diferencian de los SERIAL de toda la vida, qué son las columnas generadas, cómo crear tablas temporales o a partir de una consulta, y cómo se borran respetando el orden que impone la integridad referencial. Al terminar podrás leer el script tiendaverde.sql de arriba abajo entendiendo por qué cada decisión es la que es, y podrás escribir uno tuyo.

Contenido

  1. Del catálogo muerto a la tienda viva
  2. Anatomía de CREATE TABLE
  3. Las restricciones, una a una
  4. A nivel de columna o a nivel de tabla
  5. Nombrar las restricciones y por qué importa
  6. Columnas de identidad: IDENTITY, SERIAL y el resto de motores
  7. Columnas generadas
  8. IF NOT EXISTS, CREATE TABLE AS SELECT y tablas temporales
  9. DROP TABLE y el orden que impone la integridad referencial
  10. Recorrido comentado del DDL de TiendaVerde
  11. Errores Comunes y Consejos
  12. Ejercicios
  13. Conclusión

  1. Del catálogo muerto a la tienda viva

El módulo 4 se cerró con una frase: "una tienda que no puede dar de alta un producto, registrar un pedido, corregir un precio ni cancelar una compra no es una tienda: es un catálogo muerto". Cruzar al otro lado empieza aquí, y empieza por lo más básico: antes de poder insertar una fila hay que tener dónde ponerla.

Un recordatorio de 01-01 sobre los cinco sublenguajes de SQL, ahora con el módulo 5 situado en el mapa:

Sublenguaje Instrucciones Dónde se estudia
DQL — consulta SELECT Módulos 2, 3, 4, 6, 7
DDL — definición CREATE, ALTER, DROP, TRUNCATE 05-01 y 05-06
DML — manipulación INSERT, UPDATE, DELETE, MERGE 05-02 a 05-05
TCL — transacciones BEGIN, COMMIT, ROLLBACK Módulo 9 (uso básico desde 05-03)
DCL — control GRANT, REVOKE Lección 11-03

Y un cambio de mentalidad que conviene interiorizar ya: el DDL define las reglas que la base de datos hará cumplir por ti. Cada NOT NULL, cada CHECK, cada FOREIGN KEY que escribas es un error que tu aplicación no podrá cometer nunca, ni hoy ni dentro de tres años, ni desde el código, ni desde un script, ni desde una consola abierta a las tres de la mañana. Es la diferencia entre confiar en que todo el mundo se acuerde de validar y que sea imposible no validar.

  1. Anatomía de CREATE TABLE

La forma general:

CREATE TABLE nombre_tabla (
    columna1  TIPO  [restricciones de columna],
    columna2  TIPO  [restricciones de columna],
    ...
    [restricciones de tabla]
);

Empecemos por la tabla más simple de TiendaVerde, categorias, tal cual está en el script del curso:

CREATE TABLE categorias (
    id          INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    nombre      VARCHAR(60)  NOT NULL UNIQUE,
    descripcion TEXT
);
CREATE TABLE

Tres columnas y cuatro decisiones ya tomadas:

Elemento Decisión Por qué
id INTEGER Clave subrogada 01-05: uniformidad, estabilidad y eficiencia
GENERATED BY DEFAULT AS IDENTITY El motor genera el valor Sin secuencias manuales ni riesgo de colisión
nombre VARCHAR(60) NOT NULL UNIQUE Obligatorio y único Es la clave natural de la tabla (01-05, sección 3)
descripcion TEXT Sin restricciones Puede faltar y no tiene límite razonable de longitud

Fíjate en que descripcion no lleva NULL explícito. En SQL, una columna admite nulos salvo que digas lo contrario. Escribir descripcion TEXT NULL es legal y significa exactamente lo mismo; el curso no lo hace porque añade ruido.

Comprobar lo que has creado

Dentro de psql, \d te devuelve la definición real tal como la ve el motor:

tiendaverde=> \d categorias
                             Table "public.categorias"
   Column    |         Type          | Nullable |           Default
-------------+-----------------------+----------+----------------------------------
 id          | integer               | not null | generated by default as identity
 nombre      | character varying(60) | not null |
 descripcion | text                  |          |
Indexes:
    "categorias_pkey" PRIMARY KEY, btree (id)
    "categorias_nombre_key" UNIQUE CONSTRAINT, btree (nombre)
Referenced by:
    TABLE "productos" CONSTRAINT "productos_categoria_id_fkey" FOREIGN KEY (categoria_id) REFERENCES categorias(id) ON DELETE RESTRICT

Esa salida es tu mejor herramienta de diagnóstico durante todo el módulo. Te dice los tipos, si admiten nulos, los valores por omisión, los índices que respaldan la PK y el UNIQUE, y qué otras tablas dependen de esta. Cógele cariño.

  1. Las restricciones, una a una

SQL tiene seis restricciones declarativas. Estas son, en orden de menor a mayor alcance:

Restricción Qué garantiza Alcance
NOT NULL La columna siempre tiene valor Una celda
DEFAULT Valor si no se indica (no es una restricción estricta) Una celda
CHECK El valor cumple una condición Una fila
UNIQUE No hay dos filas con el mismo valor La tabla
PRIMARY KEY UNIQUE + NOT NULL, y solo una por tabla La tabla
FOREIGN KEY El valor existe en otra tabla Dos tablas

3.1. NOT NULL

La más simple y la más olvidada.

nombre VARCHAR(150) NOT NULL

Cualquier INSERT o UPDATE que deje esa columna sin valor se rechaza:

ERROR:  null value in column "nombre" of relation "productos" violates not-null constraint
DETAIL:  Failing row contains (21, null, 1, 1, 2.60, 1.15, 140, t, 2026-03-01).

La decisión de si una columna admite nulos no es técnica, es de negocio, y ya la tomaste conceptualmente en 04-03. En TiendaVerde:

Columna ¿Nulos? Razón
pedidos.cliente_id No Un pedido sin cliente no significa nada
pedidos.empleado_id NULL = pedido web, sin comercial. Es un dato que no existe
productos.coste Puede desconocerse al dar de alta un producto
productos.precio No Sin precio no se puede vender

Regla práctica: declara NOT NULL por defecto y admite nulos solo cuando tengas una respuesta clara a la pregunta "¿qué significa que aquí no haya nada?". Una columna nulable sin semántica documentada es una fuente garantizada de bugs.

3.2. DEFAULT

El valor que se usa si el INSERT no menciona la columna:

stock      INTEGER NOT NULL DEFAULT 0,
activo     BOOLEAN NOT NULL DEFAULT TRUE,
fecha_alta DATE    NOT NULL DEFAULT CURRENT_DATE

Los tres casos que verás en la práctica:

Tipo de DEFAULT Ejemplo Cuándo se evalúa
Constante DEFAULT 0, DEFAULT TRUE, DEFAULT 'pendiente' Se guarda tal cual
Función DEFAULT CURRENT_DATE, DEFAULT NOW() En el momento de insertar cada fila, no al crear la tabla
Expresión DEFAULT (CURRENT_DATE + 30) Igual: en cada inserción

Ese matiz de la función es importante y confunde a mucha gente: DEFAULT CURRENT_DATE no congela la fecha en que creaste la tabla. Cada fila recibe la fecha del día en que se insertó.

-- Al dar de alta un producto hoy, fecha_alta se rellena sola
INSERT INTO productos (nombre, categoria_id, proveedor_id, precio, coste, stock)
VALUES ('Garbanzos ecológicos 500 g', 1, 1, 2.60, 1.15, 140);

Y las tres columnas omitidas (activo, fecha_alta y la propia id) se rellenan con sus valores por omisión. DEFAULT es lo que hace que un INSERT corto siga produciendo una fila completa y coherente.

DEFAULT no es una restricción. No impide nada: solo rellena huecos. Si insertas NULL explícitamente en una columna con DEFAULT, se guarda NULL (o falla, si hay NOT NULL). El DEFAULT solo actúa cuando omites la columna.

3.3. PRIMARY KEY

Marca la columna —o el conjunto de columnas— que identifica cada fila. Implica UNIQUE y NOT NULL a la vez, y solo puede haber una por tabla.

Forma simple, a nivel de columna:

id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY

Forma compuesta, obligatoriamente a nivel de tabla, porque afecta a varias columnas:

-- Ejemplo didáctico: una tabla puente SIN id propio.
-- No forma parte de TiendaVerde; sirve para ver la sintaxis.
CREATE TABLE productos_etiquetas (
    producto_id  INTEGER NOT NULL REFERENCES productos(id)  ON DELETE CASCADE,
    etiqueta     VARCHAR(40) NOT NULL,
    fecha_alta   DATE NOT NULL DEFAULT CURRENT_DATE,
    PRIMARY KEY (producto_id, etiqueta)
);
CREATE TABLE

Se lee: "un producto no puede llevar dos veces la misma etiqueta". La PK compuesta es la regla de negocio; no hace falta ningún UNIQUE adicional.

Esta era exactamente la alternativa que 01-05 planteó para lineas_pedido y que TiendaVerde no eligió:

-- Alternativa NO elegida para lineas_pedido
PRIMARY KEY (pedido_id, producto_id)

Habría significado "un producto solo puede aparecer una vez en cada pedido", y eso impediría facturar dos líneas del mismo producto con descuentos distintos. Por eso lineas_pedido tiene su propio id.

PK compuesta id subrogado + UNIQUE
Expresa la regla de negocio Directamente Con un UNIQUE aparte
Referenciar la fila desde otra tabla Hay que copiar todas las columnas Basta un INTEGER
Uniformidad del esquema Rompe el patrón id Lo mantiene
Cuándo elegirla Tablas puente puras, sin hijos Casi siempre lo demás

3.4. UNIQUE

Impide valores repetidos. A diferencia de PRIMARY KEY, puedes tener tantos como quieras y sí admite nulos.

email VARCHAR(120) NOT NULL UNIQUE

Al intentar registrar dos veces el mismo email:

ERROR:  duplicate key value violates unique constraint "clientes_email_key"
DETAIL:  Key (email)=([email protected]) already exists.

Único compuesto, a nivel de tabla:

-- Un cliente, una reseña por producto.
-- Ejemplo puntual: TiendaVerde NO lleva hoy esta restricción.
CREATE TABLE resenas_v2 (
    id          INTEGER  GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    producto_id INTEGER  NOT NULL REFERENCES productos(id) ON DELETE CASCADE,
    cliente_id  INTEGER  NOT NULL REFERENCES clientes(id)  ON DELETE CASCADE,
    puntuacion  SMALLINT NOT NULL CHECK (puntuacion BETWEEN 1 AND 5),
    fecha       DATE     NOT NULL,
    UNIQUE (producto_id, cliente_id)
);

Ojo con la semántica del UNIQUE compuesto: prohíbe repetir la combinación, no cada columna por separado. El mismo cliente puede reseñar veinte productos y el mismo producto puede recibir veinte reseñas; lo que no puede haber es dos filas con el mismo par.

UNIQUE y los NULL: retomando 04-03

Aquí vuelve la lógica de tres valores. Como NULL no es igual a NULL, una columna UNIQUE puede contener muchas filas nulas:

CREATE TABLE prueba_unique (
    id     INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    codigo VARCHAR(20) UNIQUE
);

INSERT INTO prueba_unique (codigo) VALUES ('A'), (NULL), (NULL), (NULL);
INSERT 0 4
SELECT id, codigo FROM prueba_unique ORDER BY id;
id codigo
1 A
2 (null)
3 (null)
4 (null)

Tres nulos conviven sin problema en una columna UNIQUE, mientras que un segundo 'A' habría fallado. Desde PostgreSQL 15 puedes cambiar ese comportamiento:

codigo VARCHAR(20) UNIQUE NULLS NOT DISTINCT

Con eso, el segundo NULL daría error. Es útil cuando el nulo significa "sin código" y quieres que solo pueda haber una fila así.

Nota de dialecto: este comportamiento no es universal. PostgreSQL, Oracle y SQLite permiten varios nulos en una columna única; SQL Server permite solo uno (trata todos los NULL como iguales a efectos del índice único). Si migras un esquema entre motores, es una de las trampas más silenciosas.

Y una nota que verás desarrollada en el módulo 8: tanto PRIMARY KEY como UNIQUE se implementan creando un índice por debajo. Por eso son restricciones baratas de comprobar y por eso ocupan espacio en disco. Las estructuras y el coste, en su momento.

3.5. CHECK

Restringe los valores admisibles con una expresión booleana. Es la restricción más expresiva y la más infrautilizada.

TiendaVerde usa seis tipos de CHECK. Estos son los reales del script:

-- Dominio cerrado de valores (pedidos)
estado      VARCHAR(20) NOT NULL
            CHECK (estado IN ('pendiente','pagado','enviado','entregado','cancelado')),
metodo_pago VARCHAR(20) NOT NULL
            CHECK (metodo_pago IN ('tarjeta','transferencia','paypal','contrareembolso')),

-- Rango cerrado (resenas)
puntuacion  SMALLINT NOT NULL CHECK (puntuacion BETWEEN 1 AND 5),

-- Positividad estricta (lineas_pedido)
cantidad    INTEGER NOT NULL CHECK (cantidad > 0),

-- Fracción entre 0 y 1 (lineas_pedido)
descuento   NUMERIC(4,2) NOT NULL DEFAULT 0
            CHECK (descuento >= 0 AND descuento <= 1),

-- No negatividad del dinero (productos, pedidos, devoluciones, empleados)
precio      NUMERIC(10,2) NOT NULL CHECK (precio >= 0)

Cada uno protege una regla que el tipo por sí solo no garantiza. SMALLINT admite 7 y admite −3; el CHECK es lo que impide una reseña de 7 estrellas. NUMERIC(4,2) admite 99.99; el CHECK es lo que impide un descuento del 9999 %.

Un CHECK con más de una columna debe declararse a nivel de tabla, porque a nivel de columna solo puede referirse a la suya:

-- Ejemplo puntual, no está en TiendaVerde
CREATE TABLE productos_v2 (
    id     INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    nombre VARCHAR(150)  NOT NULL,
    precio NUMERIC(10,2) NOT NULL,
    coste  NUMERIC(10,2),
    CHECK (coste IS NULL OR coste <= precio)     -- margen nunca negativo
);

Fíjate en el coste IS NULL OR. Sin él, la restricción sería inútil de una forma sutil: NULL <= precio da UNKNOWN, y un CHECK que da UNKNOWN se considera satisfecho. Es la regla que más sorprende de los CHECK:

Resultado de la expresión ¿Se acepta la fila?
TRUE
FALSE No
UNKNOWN (por un NULL)

Es decir, CHECK deja pasar los nulos salvo que los prohíbas expresamente. Si quieres exigir valor, eso es trabajo de NOT NULL, no del CHECK.

Qué no puede hacer un CHECK:

  • No puede consultar otras tablas. CHECK (precio > (SELECT AVG(precio) FROM productos)) no es válido: PostgreSQL rechaza subconsultas en un CHECK. Para reglas entre tablas están las claves foráneas y, si no bastan, los triggers del módulo 10.
  • No puede usar funciones no deterministas. CHECK (fecha_alta <= CURRENT_DATE) está desaconsejado y PostgreSQL lo permite pero avisa en la documentación: una fila válida hoy podría dejar de serlo mañana, y una restauración de copia de seguridad fallaría sin motivo aparente.

3.6. FOREIGN KEY, ON DELETE y ON UPDATE

La restricción que conecta las tablas y garantiza la integridad referencial de 01-05. Tiene dos sintaxis equivalentes:

-- A nivel de columna, con REFERENCES (la que usa TiendaVerde)
categoria_id INTEGER REFERENCES categorias(id) ON DELETE RESTRICT

-- A nivel de tabla, con FOREIGN KEY (obligatoria si la clave es compuesta)
FOREIGN KEY (categoria_id) REFERENCES categorias(id) ON DELETE RESTRICT

La forma completa:

[CONSTRAINT nombre]
FOREIGN KEY (col1 [, col2 ...])
REFERENCES tabla_padre (col1 [, col2 ...])
[ON DELETE acción]
[ON UPDATE acción]

Y las cinco acciones posibles, ya conocidas de 01-05, ahora con su sintaxis:

Acción Sintaxis Qué hace al borrar el padre
Por defecto (nada)NO ACTION Rechaza con error, comprobando al final de la sentencia
Rechazo inmediato ON DELETE RESTRICT Rechaza sin esperar
Propagar el borrado ON DELETE CASCADE Borra también las filas hijas
Anular la referencia ON DELETE SET NULL Pone NULL en la FK de las hijas (exige columna nulable)
Valor por omisión ON DELETE SET DEFAULT Pone el DEFAULT de la columna hija (que debe existir en el padre)

La distinción NO ACTION / RESTRICT es sutil y casi siempre irrelevante: NO ACTION permite que otra parte de la misma sentencia arregle la situación antes de la comprobación final; RESTRICT no. En la práctica, ambos se traducen en "no me dejes hacerlo".

Aplicado al esquema real:

CREATE TABLE lineas_pedido (
    id              INTEGER       GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    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 CHECK (cantidad > 0),
    precio_unitario NUMERIC(10,2) NOT NULL CHECK (precio_unitario >= 0),
    descuento       NUMERIC(4,2)  NOT NULL DEFAULT 0
                    CHECK (descuento >= 0 AND descuento <= 1)
);

Dos FK en la misma tabla con acciones opuestas, y las dos correctas: una línea no existe sin su pedido (CASCADE), pero un producto vendido no debe poder borrarse jamás (RESTRICT), porque destruiría el histórico de facturación.

Sobre ON UPDATE: se dispara cuando cambia la clave primaria del padre. Con claves subrogadas no se usa nunca, porque un id autoincremental no cambia. Es precisamente una de las ventajas de las claves subrogadas que 01-05 enumeraba. Si tu PK fuera natural (un código de artículo, un NIF), ON UPDATE CASCADE pasaría a ser imprescindible.

Todas las FK de TiendaVerde, con su sintaxis

Tabla Columna Referencia Acción declarada
productos categoria_id categorias(id) ON DELETE RESTRICT
productos proveedor_id proveedores(id) ON DELETE RESTRICT
clientes referido_por_id clientes(id) ON DELETE SET NULL
empleados jefe_id empleados(id) ON DELETE SET NULL
pedidos cliente_id clientes(id) ON DELETE RESTRICT
pedidos empleado_id empleados(id) ON DELETE SET NULL
lineas_pedido pedido_id pedidos(id) ON DELETE CASCADE
lineas_pedido producto_id productos(id) ON DELETE RESTRICT
resenas producto_id productos(id) ON DELETE CASCADE
resenas cliente_id clientes(id) ON DELETE CASCADE
devoluciones pedido_id pedidos(id) ON DELETE CASCADE

Once claves foráneas, cuatro CASCADE, tres SET NULL y cuatro RESTRICT. Verás las tres acciones en funcionamiento, con recuentos antes y después, en la lección 05-04.

  1. A nivel de columna o a nivel de tabla

Todas las restricciones salvo NOT NULL y DEFAULT se pueden escribir de dos formas.

A nivel de columna, justo detrás del tipo:

CREATE TABLE proveedores (
    id     INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    nombre VARCHAR(120) NOT NULL,
    pais   VARCHAR(60)  NOT NULL,
    email  VARCHAR(120),
    activo BOOLEAN      NOT NULL DEFAULT TRUE
);

A nivel de tabla, al final, como elementos separados por comas:

CREATE TABLE proveedores (
    id     INTEGER      GENERATED BY DEFAULT AS IDENTITY,
    nombre VARCHAR(120) NOT NULL,
    pais   VARCHAR(60)  NOT NULL,
    email  VARCHAR(120),
    activo BOOLEAN      NOT NULL DEFAULT TRUE,
    PRIMARY KEY (id),
    CHECK (pais IN ('España','Portugal','Francia','Alemania'))
);

Cuándo usar cada una:

Situación Forma
Restricción sobre una columna Nivel de columna: más compacto y se lee al lado del tipo
Restricción sobre varias columnas Obligatoriamente nivel de tabla (PRIMARY KEY (a, b), UNIQUE (a, b), CHECK (a <= b))
Quieres nombrar la restricción Cualquiera de las dos, pero a nivel de tabla queda más legible

  1. Nombrar las restricciones y por qué importa

Si no le pones nombre, PostgreSQL genera uno siguiendo un patrón fijo:

Restricción Nombre autogenerado
PRIMARY KEY <tabla>_pkey
UNIQUE <tabla>_<columna>_key
FOREIGN KEY <tabla>_<columna>_fkey
CHECK <tabla>_<columna>_check
NOT NULL (no es una restricción con nombre propio)

Por eso todos los errores que has ido viendo en el curso tienen esa forma: categorias_nombre_key, resenas_puntuacion_check, pedidos_cliente_id_fkey.

El patrón funciona bien mientras hay una restricción por columna. En cuanto hay dos, PostgreSQL empieza a numerar:

CREATE TABLE demo_nombres (
    id     INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    precio NUMERIC(10,2) NOT NULL CHECK (precio >= 0) CHECK (precio < 10000)
);
tiendaverde=> \d demo_nombres
Check constraints:
    "demo_nombres_precio_check" CHECK (precio >= 0::numeric)
    "demo_nombres_precio_check1" CHECK (precio < 10000::numeric)

demo_nombres_precio_check1. ¿Cuál de las dos era? Imposible saberlo sin mirar la definición. Y ahora imagina ese nombre en un mensaje de error, a las once de la noche, en un log de producción.

La versión con nombres explícitos:

CREATE TABLE demo_nombres (
    id     INTEGER GENERATED BY DEFAULT AS IDENTITY,
    precio NUMERIC(10,2) NOT NULL,
    CONSTRAINT pk_demo_nombres         PRIMARY KEY (id),
    CONSTRAINT chk_demo_precio_min     CHECK (precio >= 0),
    CONSTRAINT chk_demo_precio_maximo  CHECK (precio < 10000)
);

Y el error deja de ser un jeroglífico:

ERROR:  new row for relation "demo_nombres" violates check constraint "chk_demo_precio_maximo"
DETAIL:  Failing row contains (1, 25000.00).

Aplicado a TiendaVerde, la tabla pedidos quedaría así:

CREATE TABLE pedidos (
    id           INTEGER       GENERATED BY DEFAULT AS IDENTITY,
    cliente_id   INTEGER       NOT NULL,
    empleado_id  INTEGER,
    fecha_pedido DATE          NOT NULL,
    estado       VARCHAR(20)   NOT NULL,
    metodo_pago  VARCHAR(20)   NOT NULL,
    gastos_envio NUMERIC(10,2) NOT NULL DEFAULT 0,

    CONSTRAINT pk_pedidos          PRIMARY KEY (id),
    CONSTRAINT fk_pedidos_cliente  FOREIGN KEY (cliente_id)
               REFERENCES clientes(id)  ON DELETE RESTRICT,
    CONSTRAINT fk_pedidos_empleado FOREIGN KEY (empleado_id)
               REFERENCES empleados(id) ON DELETE SET NULL,
    CONSTRAINT chk_pedidos_estado
               CHECK (estado IN ('pendiente','pagado','enviado','entregado','cancelado')),
    CONSTRAINT chk_pedidos_metodo_pago
               CHECK (metodo_pago IN ('tarjeta','transferencia','paypal','contrareembolso')),
    CONSTRAINT chk_pedidos_gastos_envio
               CHECK (gastos_envio >= 0)
);

Las tres razones por las que merece la pena esa verbosidad:

  1. Mensajes de error legibles. chk_pedidos_estado te dice qué regla has roto sin abrir el esquema.
  2. Migraciones. Para quitar o modificar una restricción hay que nombrarla: ALTER TABLE pedidos DROP CONSTRAINT chk_pedidos_estado; (lección 05-06). Con nombres autogenerados, cada migración empieza con una búsqueda arqueológica en information_schema.
  3. Portabilidad y reproducibilidad. Dos entornos creados con scripts ligeramente distintos pueden acabar con ..._check y ..._check1 intercambiados. Los nombres explícitos eliminan esa lotería.

Una convención de nombres razonable y muy extendida:

Prefijo Restricción Ejemplo
pk_ PRIMARY KEY pk_pedidos
fk_ FOREIGN KEY fk_pedidos_cliente
uq_ UNIQUE uq_clientes_email
chk_ CHECK chk_pedidos_estado

Por qué el script del curso no los usa. tiendaverde.sql prescinde de los nombres explícitos deliberadamente: así los mensajes de error que ves en las lecciones son los que PostgreSQL genera de fábrica, que es lo que te encontrarás al conectarte a cualquier base de datos ajena. En un proyecto tuyo, nómbralas.

  1. Columnas de identidad: IDENTITY, SERIAL y el resto de motores

Todas las PK de TiendaVerde son así:

id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY

Detrás hay una secuencia: un objeto del motor que va entregando números crecientes. Cuando insertas sin indicar id, PostgreSQL pide el siguiente valor a la secuencia.

BY DEFAULT frente a ALWAYS

Son dos variantes con una diferencia importante:

GENERATED BY DEFAULT AS IDENTITY GENERATED ALWAYS AS IDENTITY
Omitir el id al insertar Lo genera la secuencia Lo genera la secuencia
Indicar el id explícitamente Se acepta tu valor Error, salvo OVERRIDING SYSTEM VALUE
Riesgo de desajuste de la secuencia No
Uso típico Cargas iniciales, migraciones, datos de prueba Producción estricta

Con ALWAYS:

CREATE TABLE demo_identidad (
    id     INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    nombre VARCHAR(40) NOT NULL
);

INSERT INTO demo_identidad (id, nombre) VALUES (1, 'Forzado');
ERROR:  cannot insert a non-DEFAULT value into column "id"
DETAIL:  Column "id" is an identity column defined as GENERATED ALWAYS.
HINT:  Use OVERRIDING SYSTEM VALUE to override.

TiendaVerde usa BY DEFAULT justamente porque su script de carga inserta los id a mano (INSERT INTO categorias (id, nombre, ...) VALUES (1, ...)), y eso permite que los ejemplos del curso hablen del "cliente 7" o del "pedido 12" con ids estables y reproducibles. El precio de esa comodidad es el bloque de setval del final del script, que verás explicado en 05-02.

Si el id de tu tabla no se va a fijar nunca a mano, GENERATED ALWAYS es más seguro: elimina de raíz la posibilidad de desajustar la secuencia.

El antiguo SERIAL

Antes de PostgreSQL 10 no existía IDENTITY y se usaba el pseudotipo SERIAL:

-- Estilo antiguo, todavía muy frecuente en código existente
id SERIAL PRIMARY KEY

SERIAL no es un tipo real: es azúcar sintáctico que PostgreSQL expande a tres cosas:

CREATE SEQUENCE productos_id_seq;
id INTEGER NOT NULL DEFAULT nextval('productos_id_seq');
ALTER SEQUENCE productos_id_seq OWNED BY productos.id;
SERIAL GENERATED ... AS IDENTITY
Estándar SQL No, es de PostgreSQL (SQL:2003)
Variantes SMALLSERIAL, SERIAL, BIGSERIAL SMALLINT, INTEGER, BIGINT + IDENTITY
Puede impedir valores manuales No Sí, con ALWAYS
La secuencia se borra con la tabla Sí (por OWNED BY)
Permisos Hay que conceder permiso sobre la secuencia aparte Gestionado con la tabla
Recomendación actual Código heredado Preferida en obra nueva

Sabrás reconocer SERIAL en cualquier esquema antiguo; escribe IDENTITY en el tuyo.

Autoincremento en los demás motores

Es una de las divergencias más grandes entre dialectos:

Motor Sintaxis habitual Notas
PostgreSQL 16 INTEGER GENERATED BY DEFAULT AS IDENTITY También SERIAL (heredado). Estándar
MySQL / MariaDB INT AUTO_INCREMENT PRIMARY KEY Solo una por tabla y debe estar indexada. MySQL 8 no soporta IDENTITY
SQLite INTEGER PRIMARY KEY (o AUTOINCREMENT) INTEGER PRIMARY KEY ya es un alias de rowid y autoincrementa; AUTOINCREMENT solo añade la garantía de no reutilizar ids borrados
SQL Server INT IDENTITY(1,1) PRIMARY KEY Los parámetros son semilla e incremento. Desde 2012 también hay secuencias
Oracle NUMBER GENERATED BY DEFAULT AS IDENTITY (12c+) Antes: secuencia explícita + trigger BEFORE INSERT

Nota de dialecto: si escribes DDL que deba funcionar en varios motores, la columna de identidad será casi siempre lo primero que tengas que bifurcar. Es una de las razones por las que las herramientas de migración del apartado final de 05-06 existen.

  1. Columnas generadas

Una columna generada es una columna cuyo valor se calcula a partir de otras columnas de la misma fila. No se inserta ni se actualiza: el motor la mantiene.

-- Ejemplo puntual: variante de lineas_pedido con el importe calculado.
-- NO forma parte del esquema de TiendaVerde.
CREATE TABLE lineas_pedido_v2 (
    id              INTEGER       GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    pedido_id       INTEGER       NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
    producto_id     INTEGER       NOT NULL REFERENCES productos(id),
    cantidad        INTEGER       NOT NULL CHECK (cantidad > 0),
    precio_unitario NUMERIC(10,2) NOT NULL CHECK (precio_unitario >= 0),
    descuento       NUMERIC(4,2)  NOT NULL DEFAULT 0
                    CHECK (descuento >= 0 AND descuento <= 1),
    importe         NUMERIC(12,4)
                    GENERATED ALWAYS AS (cantidad * precio_unitario * (1 - descuento)) STORED
);
CREATE TABLE

Ahora la expresión que llevas escribiendo desde 02-02 —cantidad * precio_unitario * (1 - descuento)— vive en el esquema, no en cada consulta:

INSERT INTO lineas_pedido_v2 (pedido_id, producto_id, cantidad, precio_unitario, descuento)
VALUES (20, 5, 8, 1.95, 0.15);
INSERT 0 1
SELECT id, cantidad, precio_unitario, descuento, importe FROM lineas_pedido_v2;
id cantidad precio_unitario descuento importe
1 8 1.95 0.15 13.2600

Y si intentas escribir en ella:

-- ⚠️ INCORRECTA
UPDATE lineas_pedido_v2 SET importe = 99 WHERE id = 1;
ERROR:  column "importe" can only be updated to DEFAULT
DETAIL:  Column "importe" is a generated column.

Reglas de las columnas generadas en PostgreSQL 16:

  • Deben ser STORED (se guardan en disco). Las VIRTUAL (calculadas al leer) aún no están soportadas.
  • La expresión debe ser inmutable: solo columnas de la misma fila y funciones deterministas. Nada de CURRENT_DATE, ni subconsultas, ni otras tablas.
  • No puede tener DEFAULT ni ser columna de identidad.

¿Conviene ponerla en lineas_pedido?

Es una decisión de diseño con argumentos en ambos sentidos:

A favor En contra
La fórmula se escribe una vez y no puede divergir entre consultas Ocupa espacio en disco en todas las filas
Imposible olvidarse del (1 - descuento), el error clásico de 02-02 Cambiar la fórmula obliga a un ALTER TABLE que reescribe la tabla
Se puede indexar y agregar directamente Es redundancia: viola la 3FN (01-05) de forma controlada
Blinda el cálculo frente a aplicaciones distintas que atacan la misma base Un SELECT con la expresión cuesta prácticamente lo mismo

TiendaVerde no la usa, por dos razones didácticas y una práctica: escribir la expresión a mano es exactamente lo que te ha enseñado a pensar en importes durante tres módulos; el descuento como fracción ya es una decisión de diseño explicada; y con 47 filas el ahorro sería cero. En un sistema real con millones de líneas y una decena de aplicaciones consultando, la balanza se inclina claramente a favor.

Nota de dialecto: las columnas generadas son bastante portables. MySQL 5.7+ las tiene con GENERATED ALWAYS AS (...) STORED | VIRTUAL; SQLite 3.31+ igual; SQL Server las llama computed columns (AS expresión [PERSISTED]); Oracle usa GENERATED ALWAYS AS (...) VIRTUAL. La sintaxis varía poco, pero quién soporta VIRTUAL sí varía, y PostgreSQL es de los que no.

  1. IF NOT EXISTS, CREATE TABLE AS SELECT y tablas temporales

CREATE TABLE IF NOT EXISTS

Evita el error si la tabla ya existe:

CREATE TABLE IF NOT EXISTS categorias (
    id     INTEGER     GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    nombre VARCHAR(60) NOT NULL UNIQUE
);
NOTICE:  relation "categorias" already exists, skipping
CREATE TABLE

Es un aviso, no un error, y la sentencia se da por buena. Suena cómodo, pero úsalo con cuidado: si la tabla existe con una definición distinta, IF NOT EXISTS no la corrige, la ignora en silencio. Para scripts de creación desde cero, DROP TABLE IF EXISTS seguido de CREATE TABLE (lo que hace tiendaverde.sql) es más honesto: garantiza que la estructura resultante es exactamente la que has escrito. Para migraciones de verdad, ninguna de las dos: fichero versionado y ALTER TABLE (lección 05-06).

CREATE TABLE ... AS SELECT (CTAS)

Crea una tabla a partir del resultado de una consulta, deduciendo columnas y tipos:

CREATE TABLE productos_caros AS
SELECT id,
       nombre,
       precio,
       stock
FROM productos
WHERE precio > 10;
SELECT 7

La salida no es CREATE TABLE, es SELECT 7: te está diciendo cuántas filas ha copiado.

SELECT id, nombre, precio, stock FROM productos_caros ORDER BY id;
id nombre precio stock
1 Aceite de oliva virgen extra 500 ml 12.50 120
6 Crema facial de aloe vera 50 ml 18.90 60
8 Aceite corporal de almendras 200 ml 14.25 45
10 Detergente ecológico concentrado 1 L 11.20 70
13 Velas de cera de soja (pack 2) 13.75 0
15 Té verde matcha ceremonial 30 g 22.00 40
20 Cápsulas de espirulina 120 uds 16.40 55

Lo que CTAS copia y lo que no, y esto es la fuente de disgustos número uno:

Se copia No se copia
Los nombres de columna La clave primaria
Los tipos de datos Los UNIQUE, CHECK y NOT NULL
Los datos Las claves foráneas
Los valores DEFAULT y las columnas de identidad
Los índices

Es decir: productos_caros.id no es clave primaria y admite duplicados y nulos. CTAS crea un contenedor de datos, no una tabla bien definida. Sus usos legítimos son tres: copias de seguridad rápidas antes de un UPDATE peligroso (verás justamente eso en 05-03), tablas intermedias de análisis, y materializar el resultado de una consulta costosa.

Si solo quieres la estructura, sin datos:

CREATE TABLE productos_vacia AS
SELECT * FROM productos WHERE FALSE;
SELECT 0

Y si lo que quieres es una copia con las restricciones, la instrucción es otra:

CREATE TABLE productos_copia (LIKE productos INCLUDING ALL);
CREATE TABLE

LIKE ... INCLUDING ALL sí copia valores por omisión, restricciones, índices e identidad —pero no los datos ni las claves foráneas—. Es lo más parecido a "clonar la tabla" que ofrece PostgreSQL.

CREATE TEMP TABLE

Una tabla temporal existe solo dentro de tu sesión y desaparece al desconectarte:

CREATE TEMP TABLE precios_revisados AS
SELECT id, nombre, precio, ROUND(precio * 1.05, 2) AS precio_nuevo
FROM productos
WHERE categoria_id = 4;
SELECT 4
SELECT * FROM precios_revisados ORDER BY id;
id nombre precio precio_nuevo
14 Infusión de manzanilla ecológica 20 uds 3.25 3.41
15 Té verde matcha ceremonial 30 g 22.00 23.10
16 Kombucha de jengibre 750 ml 4.95 5.20
17 Zumo de naranja prensado en frío 1 L 5.40 5.67

Propiedades que la hacen útil:

  • Aislada: otra sesión no la ve, y puede tener una tabla temporal con el mismo nombre sin interferir.
  • Efímera: se borra al cerrar la sesión, o al terminar la transacción si añades ON COMMIT DROP.
  • Oculta la tabla real: si creas una TEMP TABLE productos, tus consultas pasarán a leer la temporal. Muy práctico para pruebas; muy peligroso si se te olvida.

Para trabajos de un solo paso, las CTE del módulo 10 suelen ser mejores. Las temporales brillan cuando necesitas releer el resultado intermedio varias veces.

  1. DROP TABLE y el orden que impone la integridad referencial

DROP TABLE [IF EXISTS] nombre [, ...] [CASCADE | RESTRICT];
  • RESTRICT (por omisión): falla si algún otro objeto depende de la tabla.
  • CASCADE: borra también los objetos dependientes (restricciones de otras tablas, vistas...).
  • IF EXISTS: no protesta si la tabla no existe.

Intenta borrar categorias con TiendaVerde cargada:

DROP TABLE categorias;
ERROR:  cannot drop table categorias because other objects depend on it
DETAIL:  constraint productos_categoria_id_fkey on table productos depends on table categorias
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

productos.categoria_id la referencia. Con CASCADE:

DROP TABLE categorias CASCADE;
NOTICE:  drop cascades to constraint productos_categoria_id_fkey on table productos
DROP TABLE

Lee bien ese NOTICE: no ha borrado la tabla productos, ha borrado su restricción de clave foránea. DROP TABLE ... CASCADE elimina las dependencias, no las tablas hijas. Aun así, el resultado es que productos se queda sin la barrera que protegía su categoria_id, lo cual casi nunca es lo que querías.

El orden de creación y de borrado

La integridad referencial impone un orden estricto, y es la razón de la estructura de tiendaverde.sql:

flowchart TD
    subgraph CREAR["Crear: de padres a hijos"]
        C1["1 · categorias<br/>proveedores"] --> C2["2 · productos<br/>clientes · empleados"]
        C2 --> C3["3 · pedidos"]
        C3 --> C4["4 · lineas_pedido<br/>resenas · devoluciones"]
    end
    subgraph BORRAR["Borrar: de hijos a padres"]
        B1["1 · devoluciones · resenas<br/>lineas_pedido"] --> B2["2 · pedidos"]
        B2 --> B3["3 · empleados · clientes<br/>productos"]
        B3 --> B4["4 · proveedores<br/>categorias"]
    end

Por eso el script del curso empieza así:

DROP TABLE IF EXISTS devoluciones  CASCADE;
DROP TABLE IF EXISTS resenas       CASCADE;
DROP TABLE IF EXISTS lineas_pedido CASCADE;
DROP TABLE IF EXISTS pedidos       CASCADE;
DROP TABLE IF EXISTS empleados     CASCADE;
DROP TABLE IF EXISTS clientes      CASCADE;
DROP TABLE IF EXISTS productos     CASCADE;
DROP TABLE IF EXISTS proveedores   CASCADE;
DROP TABLE IF EXISTS categorias    CASCADE;

Nueve DROP en orden inverso al de creación, con IF EXISTS (para que funcione la primera vez, cuando no hay nada) y con CASCADE (por si el orden fallara). Es lo que hace el script idempotente: puedes ejecutarlo cien veces y siempre dejará la base en el mismo estado.

También podrías escribirlo en una sola sentencia, que resuelve el orden sola:

DROP TABLE IF EXISTS
    categorias, proveedores, productos, clientes, empleados,
    pedidos, lineas_pedido, resenas, devoluciones CASCADE;

Aviso. DROP TABLE es irreversible en cuanto confirmas la transacción y no pregunta. En PostgreSQL puedes protegerte con BEGIN; ... ROLLBACK;, porque su DDL es transaccional (lección 05-06); en MySQL, no. Y hay un caso especial de las relaciones reflexivas: clientes.referido_por_id y empleados.jefe_id apuntan a su propia tabla, así que ninguna tabla externa las bloquea, pero un DROP de clientes sin CASCADE seguirá fallando por pedidos y resenas.

  1. Recorrido comentado del DDL de TiendaVerde

Ahora sí: el script de 01-06, leído con todo lo aprendido. Estas son las decisiones y su porqué.

categorias y proveedores — las tablas sin dependencias

CREATE TABLE categorias (
    id          INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    nombre      VARCHAR(60)  NOT NULL UNIQUE,
    descripcion TEXT
);

CREATE TABLE proveedores (
    id     INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    nombre VARCHAR(120) NOT NULL,
    pais   VARCHAR(60)  NOT NULL,
    email  VARCHAR(120),
    activo BOOLEAN      NOT NULL DEFAULT TRUE
);
Decisión Por qué
categorias.nombre es UNIQUE, proveedores.nombre no El nombre de categoría es la clave natural del negocio; dos proveedores podrían llamarse igual en teoría y el negocio no lo prohíbe
proveedores.email admite nulos No todos los proveedores dan contacto comercial
activo BOOLEAN NOT NULL DEFAULT TRUE Es el borrado lógico (05-04): un proveedor nuevo nace activo, y jamás se borra físicamente. El proveedor 5 está inactivo y conserva sus cuatro productos
descripcion TEXT sin longitud TEXT y VARCHAR sin límite son idénticos en PostgreSQL en rendimiento; VARCHAR(n) solo añade una comprobación de longitud

productos — el catálogo

CREATE TABLE productos (
    id           INTEGER       GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    nombre       VARCHAR(150)  NOT NULL,
    categoria_id INTEGER       REFERENCES categorias(id)  ON DELETE RESTRICT,
    proveedor_id INTEGER       REFERENCES proveedores(id) ON DELETE RESTRICT,
    precio       NUMERIC(10,2) NOT NULL CHECK (precio >= 0),
    coste        NUMERIC(10,2) CHECK (coste >= 0),
    stock        INTEGER       NOT NULL DEFAULT 0 CHECK (stock >= 0),
    activo       BOOLEAN       NOT NULL DEFAULT TRUE,
    fecha_alta   DATE          NOT NULL DEFAULT CURRENT_DATE
);
Decisión Por qué
categoria_id y proveedor_id admiten nulos Se puede dar de alta un producto antes de clasificarlo o de asignarle proveedor. La FK sigue exigiendo que, si hay valor, exista
Las dos FK son RESTRICT No se borra una categoría con productos ni un proveedor con catálogo. Se desactivan
precio NOT NULL, coste nulable Sin precio no hay venta; el coste puede desconocerse. De ahí que AVG(coste) de 04-04 ignorara nulos
NUMERIC(10,2) y no FLOAT Dinero exacto (01-04). Diez dígitos, dos decimales: hasta 99 999 999,99 €
stock CHECK (stock >= 0) Impide stock negativo. Ojo: no impide vender sin stock, porque eso es lógica de negocio en otra tabla; la base no la conoce
fecha_alta DEFAULT CURRENT_DATE Un alta sin fecha se fecha hoy sola

clientes y empleados — las reflexivas

CREATE TABLE clientes (
    id              INTEGER      GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    nombre          VARCHAR(60)  NOT NULL,
    apellidos       VARCHAR(90)  NOT NULL,
    email           VARCHAR(120) NOT NULL UNIQUE,
    ciudad          VARCHAR(80),
    pais            VARCHAR(60)  NOT NULL,
    fecha_registro  DATE         NOT NULL DEFAULT CURRENT_DATE,
    referido_por_id INTEGER      REFERENCES clientes(id) ON DELETE SET NULL
);
Decisión Por qué
email NOT NULL UNIQUE Es la clave candidata de 01-05. El id es la PK, pero el UNIQUE impide registrar dos veces al mismo cliente
referido_por_id REFERENCES clientes(id) Una tabla puede referenciarse a sí misma dentro de su propia definición. Es la relación reflexiva de los SELF JOIN de 03-06
ON DELETE SET NULL en la reflexiva Si se borra quien refirió, el referido sigue siendo cliente. Requiere que la columna sea nulable, y lo es
ciudad nulable, pais no El país siempre se conoce (determina portes e impuestos); la ciudad puede faltar

empleados sigue el mismo patrón con jefe_id, y con un detalle extra: salario NUMERIC(10,2) CHECK (salario >= 0) es nulable, porque en la práctica no todo el mundo tiene acceso a ese dato.

lineas_pedido — la tabla puente

Ya la has visto entera en el apartado 3.6. Solo un recordatorio de por qué precio_unitario está ahí duplicando productos.precio: no es redundancia, son datos distintos. Uno es el precio actual del catálogo, el otro el precio facturado. Las líneas 1 y 4 del conjunto de datos lo demuestran: 11,95 € y 17,50 € frente a los 12,50 € y 18,90 € de hoy. Es la desnormalización deliberada de 01-05, sección 8.

resenas y devoluciones — las satélite

CREATE TABLE resenas (
    id          INTEGER  GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    producto_id INTEGER  NOT NULL REFERENCES productos(id) ON DELETE CASCADE,
    cliente_id  INTEGER  NOT NULL REFERENCES clientes(id)  ON DELETE CASCADE,
    puntuacion  SMALLINT NOT NULL CHECK (puntuacion BETWEEN 1 AND 5),
    comentario  TEXT,
    fecha       DATE     NOT NULL
);

Aquí hay una decisión que conviene mirar de frente: resenas no tiene ninguna restricción que garantice que el cliente haya comprado ese producto, ni que impida que reseñe dos veces lo mismo. Lo comprobaste en el ejercicio 3 de 01-06: insertar una reseña del cliente 14 sobre un producto que nunca compró funciona.

Es intencionado, y encierra una lección importante: una restricción declarativa solo puede mirar la fila que se está insertando y, como mucho, la existencia de la clave en otra tabla. "Este cliente compró este producto" exige recorrer pedidos y lineas_pedido, y eso no cabe en un CHECK ni en una FOREIGN KEY. Necesitarías un trigger (módulo 10) o lógica de aplicación.

La regla: la base de datos protege las invariantes estructurales; la aplicación protege las reglas de proceso. Confundir ambas cosas lleva o bien a esquemas ingenuos, o bien a esquemas imposibles de mantener.

El resumen del esquema en una tabla

Tabla PK UNIQUE CHECK FK salientes Columnas nulables
categorias id nombre 0 descripcion
proveedores id 0 email
productos id 3 2 categoria_id, proveedor_id, coste
clientes id email 1 (reflexiva) ciudad, referido_por_id
empleados id 1 1 (reflexiva) jefe_id, salario, ciudad
pedidos id 3 2 empleado_id
lineas_pedido id 3 2
resenas id 1 2 comentario
devoluciones id 1 1

Errores Comunes y Consejos

  • Olvidar el NOT NULL. Una columna sin él admite nulos, y descubrirlo en producción con un AVG que devuelve un número raro es caro. Declara NOT NULL por defecto y justifica cada excepción.
  • Usar FLOAT o REAL para dinero. 0.1 + 0.2 no da 0.3 en coma flotante. Siempre NUMERIC(10,2) (01-04).
  • Creer que un CHECK impide los nulos. Un CHECK que da UNKNOWN se acepta. Si necesitas valor, es NOT NULL; si necesitas cubrir el nulo dentro del CHECK, escribe col IS NULL OR ....
  • Poner ON DELETE SET NULL en una columna NOT NULL. La tabla se crea, pero el primer borrado del padre falla con null value ... violates not-null constraint. Las dos declaraciones son incompatibles en el momento de la verdad.
  • Usar CASCADE por comodidad. Un DELETE puede propagarse por media base de datos en silencio. Resérvalo para composición real (05-04).
  • No nombrar las restricciones. Funciona hasta la primera migración; después, cada DROP CONSTRAINT empieza con una búsqueda en information_schema.
  • Crear tablas en orden alfabético. El orden lo manda la integridad referencial: padres antes que hijos, y al revés para borrar.
  • Confiar en CREATE TABLE AS SELECT como copia fiel. No copia PK, ni UNIQUE, ni CHECK, ni FK, ni identidad, ni índices. Para eso está LIKE ... INCLUDING ALL.
  • Confundir CREATE TABLE IF NOT EXISTS con una migración. Si la tabla existe con otra estructura, la ignora en silencio y te deja dos entornos distintos.
  • Consejo: escribe el DDL en un fichero versionado, nunca a mano en la consola. Es el germen de las migraciones de 05-06 y la única forma de que dos entornos sean iguales.
  • Consejo: \d tabla antes de escribir cualquier INSERT. Te ahorra la mitad de los errores del módulo.
  • Consejo: prueba tus restricciones. Escribe un INSERT que deba fallar y comprueba que falla. Una restricción que nunca has visto saltar puede no estar haciendo lo que crees.
  • Consejo: indexa las claves foráneas. PostgreSQL indexa la PK y los UNIQUE, pero no las FK. Sin ese índice los JOIN y los borrados en cascada se arrastran (módulo 8).

Ejercicios

Ejercicio 1

TiendaVerde quiere lanzar un programa de cupones de descuento. Escribe el CREATE TABLE de la tabla cupones con estos requisitos, usando restricciones con nombre explícito:

  • id entero, clave primaria generada por el motor, sin poder forzarse a mano.
  • codigo texto de hasta 20 caracteres, obligatorio y único.
  • descripcion texto libre opcional.
  • descuento fracción entre 0.01 y 0.50, obligatoria (mismo criterio que lineas_pedido.descuento).
  • fecha_inicio y fecha_fin, ambas obligatorias, con fecha_fin posterior o igual a fecha_inicio.
  • usos_maximos entero opcional; si tiene valor, debe ser mayor que 0.
  • usos_actuales entero obligatorio, con valor por omisión 0 y nunca negativo.
  • cliente_id opcional: si el cupón es nominal, apunta a un cliente; si ese cliente se borra, el cupón debe quedar sin titular en lugar de desaparecer.
  • activo booleano obligatorio, por omisión cierto.

Ejercicio 2

Para cada una de estas seis afirmaciones, di si es verdadera o falsa y justifícalo en una frase. Si puedes, compruébalo ejecutándolo.

  1. Una columna UNIQUE no puede contener dos filas con NULL en PostgreSQL.
  2. PRIMARY KEY (pedido_id, producto_id) permite que un producto aparezca dos veces en el mismo pedido.
  3. CHECK (coste <= precio) rechaza una fila con coste a NULL.
  4. DEFAULT CURRENT_DATE guarda la fecha en que se creó la tabla.
  5. CREATE TABLE copia AS SELECT * FROM productos produce una tabla con la misma clave primaria.
  6. Con GENERATED ALWAYS AS IDENTITY, el script de carga de TiendaVerde funcionaría igual.

Ejercicio 3

Este DDL tiene cinco problemas. Encuéntralos, explica qué consecuencia tiene cada uno y reescribe la tabla corregida.

-- ⚠️ INCORRECTA
CREATE TABLE incidencias (
    id            SERIAL,
    pedido_id     INTEGER REFERENCES pedidos(id) ON DELETE SET NULL,
    tipo          VARCHAR(20),
    importe       FLOAT,
    prioridad     INTEGER CHECK (prioridad BETWEEN 1 AND 5),
    fecha_apertura DATE DEFAULT CURRENT_DATE,
    fecha_cierre  DATE,
    CHECK (fecha_cierre > fecha_apertura)
);

Soluciones

Solución 1

CREATE TABLE cupones (
    id            INTEGER      GENERATED ALWAYS AS IDENTITY,
    codigo        VARCHAR(20)  NOT NULL,
    descripcion   TEXT,
    descuento     NUMERIC(4,2) NOT NULL,
    fecha_inicio  DATE         NOT NULL,
    fecha_fin     DATE         NOT NULL,
    usos_maximos  INTEGER,
    usos_actuales INTEGER      NOT NULL DEFAULT 0,
    cliente_id    INTEGER,
    activo        BOOLEAN      NOT NULL DEFAULT TRUE,

    CONSTRAINT pk_cupones            PRIMARY KEY (id),
    CONSTRAINT uq_cupones_codigo     UNIQUE (codigo),
    CONSTRAINT fk_cupones_cliente    FOREIGN KEY (cliente_id)
               REFERENCES clientes(id) ON DELETE SET NULL,
    CONSTRAINT chk_cupones_descuento CHECK (descuento >= 0.01 AND descuento <= 0.50),
    CONSTRAINT chk_cupones_fechas    CHECK (fecha_fin >= fecha_inicio),
    CONSTRAINT chk_cupones_usos_max  CHECK (usos_maximos IS NULL OR usos_maximos > 0),
    CONSTRAINT chk_cupones_usos_act  CHECK (usos_actuales >= 0)
);
CREATE TABLE

Los cuatro puntos que había que acertar:

Requisito Cómo se resuelve
"sin poder forzarse a mano" GENERATED ALWAYS, no BY DEFAULT
"fecha_fin posterior o igual" CHECK a nivel de tabla: implica dos columnas
"si tiene valor, mayor que 0" usos_maximos IS NULL OR usos_maximos > 0. Sin el IS NULL OR funcionaría igual (un CHECK con UNKNOWN se acepta), pero escribirlo hace explícita la intención
"quedar sin titular en lugar de desaparecer" ON DELETE SET NULL, y cliente_id debe ser nulable

Solución 2

# Afirmación Veredicto Justificación
1 UNIQUE no admite dos NULL Falsa NULL no es igual a NULL (04-03): puede haber tantos como quieras. Salvo UNIQUE NULLS NOT DISTINCT (PG 15+) o SQL Server, que solo admite uno
2 La PK compuesta permite repetir producto Falsa Es justo lo que impide, y por eso lineas_pedido no la usa
3 CHECK (coste <= precio) rechaza coste nulo Falsa NULL <= precio da UNKNOWN, y un CHECK con UNKNOWN se acepta
4 DEFAULT CURRENT_DATE congela la fecha de creación Falsa Se evalúa en cada inserción; cada fila lleva la fecha de su alta
5 CTAS copia la clave primaria Falsa No copia PK, UNIQUE, CHECK, FK, identidad ni índices
6 Con ALWAYS, el script de carga funcionaría igual Falsa El script inserta los id explícitamente. Con ALWAYS daría cannot insert a non-DEFAULT value into column "id" salvo que añadieras OVERRIDING SYSTEM VALUE a cada INSERT

Solución 3

Los cinco problemas:

# Problema Consecuencia
1 No hay PRIMARY KEY SERIAL genera valores crecientes pero no garantiza unicidad: nada impide insertar dos filas con el mismo id a mano. La tabla no tiene identificador fiable
2 importe FLOAT Coma flotante para dinero (01-04): errores de redondeo acumulativos. Debe ser NUMERIC(10,2)
3 tipo VARCHAR(20) sin NOT NULL ni CHECK Dominio abierto: caben 'devolucion', 'Devolución', 'DEV', '' y NULL. Los informes por tipo serán inútiles
4 pedido_id con ON DELETE SET NULL Una incidencia sin pedido no significa nada. Debería ser NOT NULL + ON DELETE CASCADE (la incidencia muere con el pedido) o RESTRICT (no se puede borrar un pedido con incidencias abiertas)
5 CHECK (fecha_cierre > fecha_apertura) con > estricto Una incidencia abierta y cerrada el mismo día se rechaza. Debe ser >=. Con fecha_cierre a NULL (incidencia abierta) sí funciona, porque el CHECK da UNKNOWN y se acepta

Y de propina, dos mejoras que no son errores pero sí malos hábitos: SERIAL en obra nueva y ninguna restricción con nombre.

-- ✅ CORRECTA
CREATE TABLE incidencias (
    id             INTEGER       GENERATED ALWAYS AS IDENTITY,
    pedido_id      INTEGER       NOT NULL,
    tipo           VARCHAR(20)   NOT NULL,
    importe        NUMERIC(10,2),
    prioridad      SMALLINT      NOT NULL DEFAULT 3,
    fecha_apertura DATE          NOT NULL DEFAULT CURRENT_DATE,
    fecha_cierre   DATE,

    CONSTRAINT pk_incidencias         PRIMARY KEY (id),
    CONSTRAINT fk_incidencias_pedido  FOREIGN KEY (pedido_id)
               REFERENCES pedidos(id) ON DELETE CASCADE,
    CONSTRAINT chk_incidencias_tipo
               CHECK (tipo IN ('devolucion','retraso','producto_danado','error_pedido','otro')),
    CONSTRAINT chk_incidencias_importe   CHECK (importe IS NULL OR importe >= 0),
    CONSTRAINT chk_incidencias_prioridad CHECK (prioridad BETWEEN 1 AND 5),
    CONSTRAINT chk_incidencias_fechas    CHECK (fecha_cierre IS NULL OR fecha_cierre >= fecha_apertura)
);

Conclusión

Ya sabes escribir el esquema que llevabas cinco módulos leyendo:

  • CREATE TABLE declara columnas con su tipo y sus restricciones, a nivel de columna (una sola columna) o a nivel de tabla (varias, obligatoriamente).
  • Las seis restricciones: NOT NULL (obligatoriedad), DEFAULT (relleno, no restricción, evaluado en cada inserción), CHECK (dominio de una fila, y acepta los nulos porque UNKNOWN se da por bueno), UNIQUE (que sí admite varios NULL en PostgreSQL, retomando 04-03), PRIMARY KEY (simple o compuesta) y FOREIGN KEY con sus ON DELETE / ON UPDATE.
  • Nombrar las restricciones (CONSTRAINT chk_pedidos_estado ...) convierte pedidos_estado_check1 en un mensaje de error legible y hace posibles las migraciones de 05-06.
  • Las columnas de identidad: GENERATED BY DEFAULT (permite forzar el id, y por eso la usa TiendaVerde) frente a GENERATED ALWAYS (más seguro), frente al antiguo SERIAL; y las cuatro sintaxis incompatibles de MySQL, SQLite, SQL Server y Oracle.
  • Las columnas generadas (GENERATED ALWAYS AS (...) STORED) pueden meter en el esquema la fórmula del importe de línea, con sus ventajas y su coste.
  • IF NOT EXISTS (que ignora en silencio una definición distinta), CTAS (que copia datos pero ninguna restricción) y CREATE TEMP TABLE (aislada y efímera).
  • DROP TABLE con RESTRICT/CASCADE, y el orden que impone la integridad referencial: crear de padres a hijos, borrar de hijos a padres. Es la estructura exacta de tiendaverde.sql y la razón de que sea idempotente.
  • Y el recorrido comentado del esquema real, con la frontera bien marcada: la base de datos protege las invariantes estructurales; las reglas de proceso —"solo puede reseñar quien compró"— son de la aplicación o de un trigger del módulo 10.

Ya tienes dónde poner los datos. En la siguiente lección, Instrucción INSERT, empezarás a ponerlos: por qué hay que listar siempre las columnas, cómo insertar cuarenta filas en una sola sentencia, qué hace realmente el bloque de setval del final del script del curso, cómo recuperar con RETURNING el id que acaba de generar el motor —la pieza que te faltaba para registrar un pedido y sus líneas—, cómo insertar el resultado de una consulta con INSERT ... SELECT, y qué significa exactamente cada uno de los cuatro errores que un INSERT puede darte.

Curso de SQL

Módulo 1: Introducción a SQL

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

Módulo 8: Índices y optimización de rendimiento

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados