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
- Del catálogo muerto a la tienda viva
- Anatomía de
CREATE TABLE - Las restricciones, una a una
- A nivel de columna o a nivel de tabla
- Nombrar las restricciones y por qué importa
- Columnas de identidad:
IDENTITY,SERIALy el resto de motores - Columnas generadas
IF NOT EXISTS,CREATE TABLE AS SELECTy tablas temporalesDROP TABLEy el orden que impone la integridad referencial- Recorrido comentado del DDL de TiendaVerde
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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.
- Anatomía de
CREATE TABLE
CREATE TABLELa 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
);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:
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 RESTRICTEsa 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.
- 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.
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 |
Sí | NULL = pedido web, sin comercial. Es un dato que no existe |
productos.coste |
Sí | Puede desconocerse al dar de alta un producto |
productos.precio |
No | Sin precio no se puede vender |
Regla práctica: declara
NOT NULLpor 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_DATELos 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.
DEFAULTno es una restricción. No impide nada: solo rellena huecos. Si insertasNULLexplícitamente en una columna conDEFAULT, se guardaNULL(o falla, si hayNOT NULL). ElDEFAULTsolo 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:
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)
);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ó:
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.
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);| 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:
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
NULLcomo 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 |
Sí |
FALSE |
No |
UNKNOWN (por un NULL) |
Sí |
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 unCHECK. 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 RESTRICTLa 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.
- 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 |
- 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)
);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:
- Mensajes de error legibles.
chk_pedidos_estadote dice qué regla has roto sin abrir el esquema. - 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 eninformation_schema. - Portabilidad y reproducibilidad. Dos entornos creados con scripts ligeramente distintos pueden acabar con
..._checky..._check1intercambiados. 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.sqlprescinde 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.
- Columnas de identidad:
IDENTITY, SERIAL y el resto de motores
IDENTITY, SERIAL y el resto de motoresTodas las PK de TiendaVerde son así:
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 | Sí | 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:
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 | Sí (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) |
Sí |
| 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.
- 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
);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);| id | cantidad | precio_unitario | descuento | importe |
|---|---|---|---|---|
| 1 | 8 | 1.95 | 0.15 | 13.2600 |
Y si intentas escribir en ella:
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). LasVIRTUAL(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
DEFAULTni 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 usaGENERATED ALWAYS AS (...) VIRTUAL. La sintaxis varía poco, pero quién soportaVIRTUALsí varía, y PostgreSQL es de los que no.
IF NOT EXISTS, CREATE TABLE AS SELECT y tablas temporales
IF NOT EXISTS, CREATE TABLE AS SELECT y tablas temporalesCREATE 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
);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:
La salida no es CREATE TABLE, es SELECT 7: te está diciendo cuántas filas ha copiado.
| 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:
Y si lo que quieres es una copia con las restricciones, la instrucción es otra:
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;| 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.
DROP TABLE y el orden que impone la integridad referencial
DROP TABLE y el orden que impone la integridad referencialRESTRICT(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:
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:
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 TABLEes irreversible en cuanto confirmas la transacción y no pregunta. En PostgreSQL puedes protegerte conBEGIN; ... 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_idyempleados.jefe_idapuntan a su propia tabla, así que ninguna tabla externa las bloquea, pero unDROPdeclientessinCASCADEseguirá fallando porpedidosyresenas.
- 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 unAVGque devuelve un número raro es caro. DeclaraNOT NULLpor defecto y justifica cada excepción. - Usar
FLOAToREALpara dinero.0.1 + 0.2no da0.3en coma flotante. SiempreNUMERIC(10,2)(01-04). - Creer que un
CHECKimpide los nulos. UnCHECKque daUNKNOWNse acepta. Si necesitas valor, esNOT NULL; si necesitas cubrir el nulo dentro delCHECK, escribecol IS NULL OR .... - Poner
ON DELETE SET NULLen una columnaNOT NULL. La tabla se crea, pero el primer borrado del padre falla connull value ... violates not-null constraint. Las dos declaraciones son incompatibles en el momento de la verdad. - Usar
CASCADEpor comodidad. UnDELETEpuede 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 CONSTRAINTempieza con una búsqueda eninformation_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 SELECTcomo copia fiel. No copia PK, niUNIQUE, niCHECK, ni FK, ni identidad, ni índices. Para eso estáLIKE ... INCLUDING ALL. - Confundir
CREATE TABLE IF NOT EXISTScon 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 tablaantes de escribir cualquierINSERT. Te ahorra la mitad de los errores del módulo. - Consejo: prueba tus restricciones. Escribe un
INSERTque 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 losJOINy 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:
identero, clave primaria generada por el motor, sin poder forzarse a mano.codigotexto de hasta 20 caracteres, obligatorio y único.descripciontexto libre opcional.descuentofracción entre 0.01 y 0.50, obligatoria (mismo criterio quelineas_pedido.descuento).fecha_inicioyfecha_fin, ambas obligatorias, confecha_finposterior o igual afecha_inicio.usos_maximosentero opcional; si tiene valor, debe ser mayor que 0.usos_actualesentero obligatorio, con valor por omisión 0 y nunca negativo.cliente_idopcional: 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.activobooleano 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.
- Una columna
UNIQUEno puede contener dos filas conNULLen PostgreSQL. PRIMARY KEY (pedido_id, producto_id)permite que un producto aparezca dos veces en el mismo pedido.CHECK (coste <= precio)rechaza una fila concosteaNULL.DEFAULT CURRENT_DATEguarda la fecha en que se creó la tabla.CREATE TABLE copia AS SELECT * FROM productosproduce una tabla con la misma clave primaria.- 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)
);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 TABLEdeclara 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 porqueUNKNOWNse da por bueno),UNIQUE(que sí admite variosNULLen PostgreSQL, retomando 04-03),PRIMARY KEY(simple o compuesta) yFOREIGN KEYcon susON DELETE/ON UPDATE. - Nombrar las restricciones (
CONSTRAINT chk_pedidos_estado ...) conviertepedidos_estado_check1en un mensaje de error legible y hace posibles las migraciones de 05-06. - Las columnas de identidad:
GENERATED BY DEFAULT(permite forzar elid, y por eso la usa TiendaVerde) frente aGENERATED ALWAYS(más seguro), frente al antiguoSERIAL; 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) yCREATE TEMP TABLE(aislada y efímera).DROP TABLEconRESTRICT/CASCADE, y el orden que impone la integridad referencial: crear de padres a hijos, borrar de hijos a padres. Es la estructura exacta detiendaverde.sqly 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
- ¿Qué es SQL?
- Configurando tu entorno SQL
- Sintaxis básica de SQL
- Entendiendo bases de datos y tablas
- El modelo relacional: claves primarias y foráneas
- La base de datos del curso: TiendaVerde
Módulo 2: Consultas básicas de SQL
- Instrucción SELECT
- Alias, expresiones y columnas calculadas
- Filtrando datos con WHERE
- DISTINCT y eliminación de duplicados
- Ordenando datos con ORDER BY
- Limitando resultados con LIMIT
Módulo 3: Trabajando con múltiples tablas
- Operaciones JOIN
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN y CROSS JOIN
- Uniones de conjuntos: UNION, INTERSECT y EXCEPT
Módulo 4: Filtrado avanzado de datos
- Usando LIKE para coincidencia de patrones
- Operadores IN y BETWEEN
- Valores NULL y IS NULL
- Funciones de agregación: COUNT, SUM, AVG, MIN y MAX
- Agregando datos con GROUP BY
- Cláusula HAVING
Módulo 5: Manipulación de datos
- Creando tablas y restricciones con CREATE TABLE
- Instrucción INSERT
- Instrucción UPDATE
- Instrucción DELETE
- Instrucción UPSERT (MERGE)
- Modificando el esquema: ALTER TABLE y migraciones seguras
Módulo 6: Funciones avanzadas de SQL
- Funciones de cadena
- Funciones numéricas
- Funciones de fecha y hora
- Conversión de tipos y manejo de NULL: CAST y COALESCE
- Expresiones condicionales
Módulo 7: Subconsultas y consultas anidadas
- Introducción a subconsultas
- Subconsultas correlacionadas
- EXISTS y NOT EXISTS
- Usando subconsultas en cláusulas SELECT, FROM y WHERE
- Subconsultas o JOIN: cuál elegir
Módulo 8: Índices y optimización de rendimiento
- Entendiendo los índices
- Creación y gestión de índices
- Tipos de índice y cuándo no indexar
- Técnicas de optimización de consultas
- Análisis del rendimiento de consultas
Módulo 9: Transacciones y concurrencia
- Introducción a las transacciones
- Propiedades ACID
- Instrucciones de control de transacciones
- Niveles de aislamiento y anomalías de concurrencia
- Manejo de concurrencia: bloqueos e interbloqueos
Módulo 10: Temas avanzados
- Vistas
- Expresiones de tabla comunes (CTE)
- Funciones de ventana
- Procedimientos almacenados
- Triggers
- JSON y datos semiestructurados
Módulo 11: SQL en la práctica
- Casos de uso en el mundo real
- Mejores prácticas
- Seguridad: inyección SQL, permisos y roles
- SQL para análisis de datos
- SQL en desarrollo web
