Los esquemas cambian. Siempre. Hace falta guardar el teléfono de los clientes, el VARCHAR(60) se queda corto, alguien se dio cuenta de que aquella columna se llamaba mal, marketing quiere una columna total en pedidos para no recalcularla en cada informe, y una regla de negocio que hasta ahora vivía en el código debería estar en un CHECK. La base de datos que creaste en 05-01 y llenaste en 05-02 no es un monumento: es un organismo en producción que va a mutar decenas de veces.
ALTER TABLE es la instrucción que lo permite. Su sintaxis se aprende en veinte minutos. Lo que separa a un profesional de un accidente no es la sintaxis: es saber qué operaciones bloquean la tabla y cuáles no, entender que en PostgreSQL una migración se puede deshacer con ROLLBACK y en MySQL no, conocer el patrón que permite cambiar un esquema sin parar el servicio, y aceptar la regla que rige toda la industria: ningún cambio de estructura se escribe a mano en una consola de producción.
Esta lección cubre las dos mitades, y cierra el módulo.
⚠️ Aviso de seguridad
ALTER TABLEes una operación destructiva e irreversible en sus efectos sobre los datos. UnDROP COLUMNelimina una columna entera y todo su contenido; unALTER COLUMN TYPEpuede truncar valores; unADD CONSTRAINTpuede rechazar filas existentes.
- Ejecuta todos los ejemplos sobre tu base de datos de prácticas (
tiendaverde), nunca en producción.- Haz una copia de seguridad previa:
pg_dump -U curso_sql -d tiendaverde -f copia.sql.- Trabaja dentro de
BEGIN…ROLLBACK: en PostgreSQL el DDL es transaccional y puedes deshacerlo.- En un sistema real, todo cambio de esquema debe ir revisado por el responsable de la base de datos, probado antes en un entorno equivalente al de producción y aplicado como migración versionada, nunca a mano.
Contenido
ALTER TABLE: el mapa de operacionesADD COLUMN, con y sinDEFAULTDROP COLUMNRENAME COLUMNyRENAME TOALTER COLUMN TYPEy la cláusulaUSINGSET/DROP NOT NULLySET/DROP DEFAULTADD/DROP CONSTRAINT, y la técnicaNOT VALID+VALIDATE- Qué bloquea la tabla y qué no
- DDL transaccional: PostgreSQL frente a MySQL
- El patrón expand/contract
- Migraciones versionadas
- Qué hacer y qué no hacer en producción
- Errores Comunes y Consejos
- Ejercicios
- Conclusión del módulo
ALTER TABLE: el mapa de operaciones
ALTER TABLE: el mapa de operacionesLas acciones más habituales:
| Acción | Qué hace |
|---|---|
ADD COLUMN col TIPO [restricciones] |
Añade una columna |
DROP COLUMN col [CASCADE] |
Elimina una columna y sus datos |
RENAME COLUMN vieja TO nueva |
Renombra una columna |
RENAME TO nueva_tabla |
Renombra la tabla |
ALTER COLUMN col TYPE nuevo_tipo [USING expr] |
Cambia el tipo |
ALTER COLUMN col SET NOT NULL / DROP NOT NULL |
Añade o quita obligatoriedad |
ALTER COLUMN col SET DEFAULT expr / DROP DEFAULT |
Añade o quita valor por omisión |
ADD CONSTRAINT nombre ... |
Añade CHECK, UNIQUE, PRIMARY KEY o FOREIGN KEY |
DROP CONSTRAINT nombre |
Elimina una restricción |
VALIDATE CONSTRAINT nombre |
Valida una restricción declarada NOT VALID |
Un detalle de sintaxis que ahorra mucho tiempo: varias acciones caben en una sola sentencia, separadas por comas. Y eso no es solo elegancia — es que la tabla se bloquea una vez en lugar de N:
ALTER TABLE clientes
ADD COLUMN telefono VARCHAR(20),
ADD COLUMN newsletter BOOLEAN NOT NULL DEFAULT FALSE; Table "public.clientes"
Column | Type | Nullable | Default
-----------------+------------------------+----------+----------------------------------
id | integer | not null | generated by default as identity
nombre | character varying(60) | not null |
apellidos | character varying(90) | not null |
email | character varying(120) | not null |
ciudad | character varying(80) | |
pais | character varying(60) | not null |
fecha_registro | date | not null | CURRENT_DATE
referido_por_id | integer | |
telefono | character varying(20) | |
newsletter | boolean | not null | falseAviso sobre los objetos de esta lección.
clientes.telefono,clientes.newsletterypedidos.totalson ejemplos puntuales: no forman parte del esquema canónico de TiendaVerde y no aparecerán en los módulos siguientes. Recargatiendaverde.sqlal terminar.
ADD COLUMN, con y sin DEFAULT
ADD COLUMN, con y sin DEFAULT(Recarga tiendaverde.sql antes de seguir: los ejemplos de este apartado vuelven a añadir las dos columnas del anterior, esta vez una a una.)
Sin DEFAULT
La columna nace nulable y todas las filas existentes quedan con NULL. Es instantáneo independientemente del tamaño de la tabla: PostgreSQL solo anota la nueva columna en su catálogo, sin tocar un solo byte de datos.
| id | nombre | apellidos | telefono |
|---|---|---|---|
| 1 | Lucía | Martínez Soler | (null) |
| 2 | Carlos | Ferrer Ibáñez | (null) |
| 3 | Marta | Sanchis Gil | (null) |
Con DEFAULT
| sin_newsletter | total |
|---|---|
| 15 | 15 |
Las quince filas tienen FALSE. Y aquí está una de las mejoras más importantes de PostgreSQL de la última década:
Desde PostgreSQL 11, añadir una columna con
DEFAULTconstante ya no reescribe la tabla. Antes, esta operación recorría y reescribía cada fila para grabar el valor: en una tabla de cien millones de filas eran horas de bloqueo exclusivo — el caso de estudio clásico de "cómo tumbar producción con una línea de SQL".Ahora PostgreSQL guarda el valor por omisión en el catálogo y lo devuelve "al vuelo" para las filas antiguas, escribiéndolo físicamente solo cuando esas filas se actualizan por otro motivo. La operación pasa de horas a milisegundos.
La excepción, que sigue siendo cara:
-- ⚠️ Esto SÍ reescribe la tabla entera: el DEFAULT es volátil
ALTER TABLE clientes
ADD COLUMN token UUID NOT NULL DEFAULT gen_random_uuid();Si el DEFAULT no es constante —una función aleatoria, un NOW() que debe diferir por fila— cada fila necesita su propio valor y no hay atajo posible. La regla:
DEFAULT |
¿Reescribe la tabla? |
|---|---|
| Ninguno | No |
Constante (0, FALSE, 'pendiente') |
No (PostgreSQL 11+) |
Función estable evaluada una vez (CURRENT_DATE) |
No: el valor se fija al ejecutar el ALTER |
Función volátil (gen_random_uuid(), random()) |
Sí, fila a fila |
Nota de dialecto: MySQL 8 con InnoDB permite
ADD COLUMNconALGORITHM=INSTANTen muchos casos (columna al final de la tabla, sin cambio de formato de fila); si no, reconstruye. Oracle tiene una optimización equivalente desde 11g. SQLite añade columnas al final de forma barata pero no permite añadirlas con unDEFAULTno constante. SQL Server distingue entre añadir una columna nulable (instantáneo) y unaNOT NULLconDEFAULT(instantáneo desde 2012 Enterprise, reescritura en el resto de ediciones).
DROP COLUMN
DROP COLUMNTambién es instantáneo: PostgreSQL no borra los datos, marca la columna como eliminada en el catálogo y deja de mostrarla. El espacio se recupera cuando cada fila se reescribe (por un UPDATE o por un VACUUM FULL).
Eso tiene dos implicaciones que conviene conocer:
- El dato sigue físicamente en disco hasta que se reescriba la fila. Si la columna contenía información sensible, un
DROP COLUMNno es un borrado seguro. - La operación es irreversible desde SQL una vez confirmada. No hay
UNDROP.
Si algo depende de la columna (una restricción, una vista, un índice), DROP COLUMN falla:
ERROR: cannot drop column precio of table productos because other objects depend on it DETAIL: constraint productos_precio_check on table productos depends on column precio of table productos HINT: Use DROP ... CASCADE to drop the dependent objects too.
Con CASCADE se lleva por delante lo que dependa:
Y con eso acabas de destruir la columna más importante del catálogo y su restricción. CASCADE en ALTER TABLE merece el mismo respeto que en DELETE.
RENAME COLUMN y RENAME TO
RENAME COLUMN y RENAME TOAmbas son puramente de catálogo: instantáneas, sin tocar datos, y reversibles con otro RENAME.
Y ambas son, en un sistema con aplicaciones conectadas, de las operaciones más peligrosas que existen. El motivo no es técnico: es que en el instante en que confirmas el cambio, todo el código que menciona el nombre antiguo deja de funcionar:
Un ALTER TABLE de dos segundos ha roto la aplicación entera. Y no hay ventana de transición: o el código usa el nombre viejo, o usa el nuevo.
La regla: un
RENAMEen producción nunca se hace de golpe. Se hace con el patrón expand/contract del apartado 10, o no se hace. Y si el nombre es feo pero funciona, muchas veces la respuesta correcta es dejarlo feo.
Deshacemos los dos cambios antes de seguir:
ALTER COLUMN TYPE y la cláusula USING
ALTER COLUMN TYPE y la cláusula USINGAmpliar la precisión de un NUMERIC funciona directamente, porque todos los valores existentes caben en el tipo nuevo. Lo mismo ocurre al pasar de VARCHAR(60) a VARCHAR(120) o a TEXT: PostgreSQL reconoce esos casos como compatibles a nivel binario y no reescribe nada.
Reducir, en cambio, puede fallar:
Y esto es una buena noticia: PostgreSQL comprueba todas las filas antes de aplicar el cambio, y si una sola no cabe, aborta sin tocar nada. Nunca trunca en silencio.
Nota de dialecto: MySQL, según su modo SQL, sí puede truncar en silencio al reducir un
VARCHAR. Consql_modeenSTRICT_TRANS_TABLES(el valor por omisión desde MySQL 5.7) da error; con el modo relajado, recorta el texto y emite un aviso que casi nadie lee. Es una de las diferencias de comportamiento más peligrosas entre motores.
La cláusula USING
Cuando la conversión no es automática, PostgreSQL te lo dice y te ofrece la salida:
-- Ejemplo puntual: tabla auxiliar con datos "sucios" de una importación
CREATE TABLE pedidos_importados (
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
referencia VARCHAR(20) NOT NULL,
fecha_texto VARCHAR(10) NOT NULL,
importe_texto VARCHAR(15) NOT NULL
);
INSERT INTO pedidos_importados (referencia, fecha_texto, importe_texto) VALUES
('TV-2026-001', '2026-03-01', '47.05'),
('TV-2026-002', '2026-03-02', '26.70'),
('TV-2026-003', '2026-03-03', '34.48');Un intento directo de convertir el texto a fecha:
ERROR: column "fecha_texto" cannot be cast automatically to type date HINT: You might need to specify "USING fecha_texto::date".
USING recibe una expresión que calcula el valor nuevo a partir del antiguo, fila a fila:
ALTER TABLE pedidos_importados
ALTER COLUMN fecha_texto TYPE DATE USING fecha_texto::DATE,
ALTER COLUMN importe_texto TYPE NUMERIC(10,2) USING importe_texto::NUMERIC(10,2);| id | referencia | fecha_texto | importe_texto |
|---|---|---|---|
| 1 | TV-2026-001 | 2026-03-01 | 47.05 |
| 2 | TV-2026-002 | 2026-03-02 | 26.70 |
| 3 | TV-2026-003 | 2026-03-03 | 34.48 |
USING admite cualquier expresión, no solo conversiones. Por ejemplo, para pasar lineas_pedido.descuento de fracción (0.10) a porcentaje entero (10):
-- Ejemplo hipotético: NO lo apliques a TiendaVerde, romperías
-- todos los cálculos de los módulos 2 a 4
ALTER TABLE lineas_pedido
ALTER COLUMN descuento TYPE SMALLINT USING (descuento * 100)::SMALLINT;Tres avisos sobre ALTER COLUMN TYPE:
| Aviso | Detalle |
|---|---|
| Reescribe la tabla entera salvo en los casos binario-compatibles | Con bloqueo exclusivo durante todo el proceso |
| Reconstruye los índices que la incluyan | Coste adicional proporcional |
| Puede invalidar restricciones y vistas dependientes | PostgreSQL las recrea si puede, y si no, falla |
SET / DROP NOT NULL y SET / DROP DEFAULT
SET / DROP NOT NULL y SET / DROP DEFAULT-- Hacer obligatoria una columna que no lo era
ALTER TABLE clientes ALTER COLUMN ciudad SET NOT NULL;Falla si hay filas con NULL… salvo que en TiendaVerde todos los clientes tengan ciudad, que es el caso. Probemos con una que sí tiene nulos:
Los diez pedidos web lo impiden, y correctamente: ese NULL significa algo (04-03).
La operación inversa siempre funciona, porque relajar una restricción nunca puede invalidar datos existentes:
Los valores por omisión funcionan igual, y son puro catálogo:
ALTER TABLE productos ALTER COLUMN stock SET DEFAULT 10;
ALTER TABLE productos ALTER COLUMN stock SET DEFAULT 0; -- lo dejamos como estaba
ALTER TABLE clientes ALTER COLUMN fecha_registro DROP DEFAULT;
ALTER TABLE clientes ALTER COLUMN fecha_registro SET DEFAULT CURRENT_DATE;Cambiar el DEFAULT no afecta a las filas existentes. Solo cambia lo que se rellenará en las inserciones futuras. Es un error clásico esperar lo contrario.
ADD / DROP CONSTRAINT, y la técnica NOT VALID + VALIDATE
ADD / DROP CONSTRAINT, y la técnica NOT VALID + VALIDATEAquí se cobra la insistencia de 05-01 en nombrar las restricciones: para quitar una hay que nombrarla, y no puedes nombrar lo que no sabes cómo se llama.
Funciona porque ninguno de los 20 productos tiene un coste superior a su precio. Si lo hubiera, PostgreSQL rechazaría el ALTER TABLE entero:
Quitarla:
Y las otras tres familias:
ALTER TABLE resenas ADD CONSTRAINT uq_resenas_producto_cliente UNIQUE (producto_id, cliente_id);
-- Sobre la tabla auxiliar de importación del apartado 5
ALTER TABLE pedidos_importados
ADD CONSTRAINT uq_pedidos_importados_ref UNIQUE (referencia);
ALTER TABLE pedidos_importados
ADD CONSTRAINT chk_pedidos_importados_importe CHECK (importe_texto >= 0);El problema: validar bloquea
Añadir un CHECK o una FOREIGN KEY obliga a PostgreSQL a comprobar todas las filas existentes, y lo hace con un bloqueo exclusivo. En una tabla de diez millones de filas eso puede ser un minuto largo durante el cual nadie puede leer ni escribir.
La solución en dos tiempos:
-- Fase 1: añadir sin validar. Instantáneo.
ALTER TABLE productos
ADD CONSTRAINT chk_productos_margen CHECK (coste IS NULL OR coste <= precio) NOT VALID;-- Fase 2: validar. Puede tardar, pero NO bloquea lecturas ni escrituras.
ALTER TABLE productos VALIDATE CONSTRAINT chk_productos_margen;Qué hace exactamente NOT VALID:
Con NOT VALID |
Tras VALIDATE CONSTRAINT |
|
|---|---|---|
| Filas nuevas o modificadas | Se comprueban desde el primer instante | Se comprueban |
| Filas existentes | No se comprueban | Se comprueban una vez |
| Bloqueo | ACCESS EXCLUSIVE, pero instantáneo |
SHARE UPDATE EXCLUSIVE: no bloquea lecturas ni escrituras |
| El planificador puede aprovecharla | No | Sí |
Es decir: NOT VALID te da la protección hacia el futuro de inmediato, y deja la comprobación del histórico para un momento tranquilo. Es la técnica estándar para añadir restricciones a tablas grandes en producción.
Y comprobar qué restricciones están sin validar:
SELECT conname AS restriccion, convalidated AS validada
FROM pg_constraint
WHERE conrelid = 'productos'::regclass
ORDER BY conname;| restriccion | validada |
|---|---|
| chk_productos_margen | true |
| productos_categoria_id_fkey | true |
| productos_coste_check | true |
| productos_pkey | true |
| productos_precio_check | true |
| productos_proveedor_id_fkey | true |
| productos_stock_check | true |
Deshacemos el ejemplo:
- Qué bloquea la tabla y qué no
Este es el apartado que separa una migración inocua de una caída de producción.
PostgreSQL protege cada operación con un nivel de bloqueo. El más agresivo es ACCESS EXCLUSIVE: mientras se mantiene, ninguna otra sesión puede ni siquiera leer la tabla. Todas las consultas se quedan esperando.
| Operación | Nivel de bloqueo | ¿Reescribe? | ¿Escanea? | Duración |
|---|---|---|---|---|
ADD COLUMN sin DEFAULT |
ACCESS EXCLUSIVE |
No | No | Instantánea |
ADD COLUMN con DEFAULT constante |
ACCESS EXCLUSIVE |
No (PG 11+) | No | Instantánea |
ADD COLUMN con DEFAULT volátil |
ACCESS EXCLUSIVE |
Sí | Sí | Proporcional al tamaño |
DROP COLUMN |
ACCESS EXCLUSIVE |
No | No | Instantánea |
RENAME COLUMN / RENAME TO |
ACCESS EXCLUSIVE |
No | No | Instantánea |
SET DEFAULT / DROP DEFAULT |
ACCESS EXCLUSIVE |
No | No | Instantánea |
DROP NOT NULL |
ACCESS EXCLUSIVE |
No | No | Instantánea |
SET NOT NULL |
ACCESS EXCLUSIVE |
No | Sí | Proporcional |
ALTER COLUMN TYPE (binario-compatible) |
ACCESS EXCLUSIVE |
No | No | Instantánea |
ALTER COLUMN TYPE (resto) |
ACCESS EXCLUSIVE |
Sí | Sí | Proporcional |
ADD CONSTRAINT CHECK |
ACCESS EXCLUSIVE |
No | Sí | Proporcional |
ADD CONSTRAINT CHECK ... NOT VALID |
ACCESS EXCLUSIVE |
No | No | Instantánea |
ADD FOREIGN KEY |
ACCESS EXCLUSIVE en ambas tablas |
No | Sí | Proporcional |
ADD FOREIGN KEY ... NOT VALID |
SHARE ROW EXCLUSIVE |
No | No | Instantánea |
VALIDATE CONSTRAINT |
SHARE UPDATE EXCLUSIVE |
No | Sí | No bloquea lecturas ni escrituras |
ADD UNIQUE / ADD PRIMARY KEY |
ACCESS EXCLUSIVE |
No | Sí (construye índice) | Proporcional |
DROP CONSTRAINT |
ACCESS EXCLUSIVE |
No | No | Instantánea |
CREATE INDEX |
SHARE |
No | Sí | Bloquea escrituras |
CREATE INDEX CONCURRENTLY |
SHARE UPDATE EXCLUSIVE |
No | Sí (dos pasadas) | No bloquea escrituras (módulo 8) |
El detalle que mata: la cola de bloqueos
Y ahora lo verdaderamente importante, que casi nadie explica:
Una operación "instantánea" no es inofensiva si no consigue el bloqueo.
Un ALTER TABLE ADD COLUMN tarda un milisegundo… una vez que ha obtenido el ACCESS EXCLUSIVE. Si en ese momento hay una consulta larga leyendo la tabla, el ALTER se pone a esperar. Y mientras espera, PostgreSQL encola detrás de él a todas las consultas nuevas, porque los bloqueos se conceden por orden de llegada.
sequenceDiagram
participant R as Informe (5 min)
participant A as ALTER TABLE
participant N as 200 consultas nuevas
R->>R: SELECT largo · bloqueo ACCESS SHARE
A->>A: pide ACCESS EXCLUSIVE → ESPERA
N->>N: piden ACCESS SHARE → esperan DETRÁS del ALTER
Note over R,N: 💥 La tabla queda inaccesible 5 minutos<br/>por un ALTER de 1 ms
Un ALTER TABLE de un milisegundo acaba de dejar la tabla inaccesible cinco minutos. La protección estándar:
Si en tres segundos no consigue el bloqueo, aborta con canceling statement due to lock timeout en lugar de bloquear la base. Se reintenta más tarde. Poner lock_timeout en toda migración es una de las prácticas más rentables que existen.
- DDL transaccional: PostgreSQL frente a MySQL
Esta es una diferencia crítica entre motores, y cambia por completo cómo se escribe una migración.
En PostgreSQL, el DDL es transaccional:
BEGIN;
ALTER TABLE clientes ADD COLUMN telefono VARCHAR(20);
ALTER TABLE clientes ADD COLUMN newsletter BOOLEAN NOT NULL DEFAULT FALSE;
SELECT id, nombre, telefono, newsletter FROM clientes ORDER BY id LIMIT 2;| id | nombre | telefono | newsletter |
|---|---|---|---|
| 1 | Lucía | (null) | false |
| 2 | Carlos | (null) | false |
ERROR: column "telefono" does not exist
LINE 1: SELECT id, nombre, telefono FROM clientes LIMIT 1;
^Como si nunca hubiera pasado. Los dos ALTER TABLE se han deshecho.
La consecuencia práctica es enorme: en PostgreSQL puedes envolver una migración de quince pasos en BEGIN … COMMIT y tener la garantía de que o se aplica entera o no se aplica nada. Si el paso 12 falla, el esquema queda exactamente como estaba.
| PostgreSQL | MySQL / MariaDB | SQL Server | Oracle | SQLite | |
|---|---|---|---|---|---|
| DDL transaccional | Sí | No | Sí | No | Sí |
ROLLBACK de un ALTER TABLE |
Funciona | Imposible | Funciona | Imposible | Funciona |
BEGIN antes de un DDL |
Se respeta | Confirma implícitamente la transacción abierta | Se respeta | Confirma implícitamente | Se respeta |
| Migración a medias posible | No | Sí | No | Sí | No |
En MySQL y Oracle, cada sentencia DDL confirma implícitamente la transacción en curso. Si una migración de quince pasos falla en el doce, los once primeros ya están aplicados y no hay forma de deshacerlos. Por eso en esos motores toda migración necesita su script de reversión escrito a mano, y por eso las herramientas del apartado 11 insisten tanto en el concepto de down migration.
Si trabajas con MySQL, interioriza esto: no existe la red de seguridad. Cada paso de la migración debe ser reversible por sí solo, y el orden importa muchísimo más.
- El patrón expand/contract
Cómo se cambia un esquema sin parar el servicio.
El problema de fondo: la base de datos y la aplicación se despliegan por separado, y durante un rato conviven la versión antigua y la nueva del código. Cualquier cambio que rompa a una de las dos provoca errores. Un RENAME COLUMN, como viste, rompe la versión antigua en el instante en que se confirma.
Expand/contract (también llamado parallel change) resuelve esto descomponiendo el cambio en cinco fases, cada una compatible hacia adelante y hacia atrás:
flowchart TD
A["1 · EXPAND<br/>Añadir lo nuevo<br/>sin tocar lo viejo"] --> B["2 · DOBLE ESCRITURA<br/>La aplicación escribe<br/>en ambos sitios"]
B --> C["3 · BACKFILL<br/>Rellenar lo nuevo<br/>con los datos históricos"]
C --> D["4 · CAMBIAR LA LECTURA<br/>La aplicación lee de lo nuevo<br/>y se verifica que coincide"]
D --> E["5 · CONTRACT<br/>Dejar de escribir en lo viejo<br/>y eliminarlo"]
style A fill:#e8f5e9
style E fill:#ffebee
La clave es que en ningún momento existe un estado en el que la aplicación pueda fallar: entre la fase 1 y la 5, ambas versiones del código funcionan.
El caso: añadir pedidos.total
TiendaVerde calcula el total de cada pedido sumando sus líneas y añadiendo los portes. Es correcto (01-05 lo justificaba: nada de agregados precalculados), pero los informes del módulo 4 repiten esa expresión una y otra vez, y con volumen alto el coste se nota. Dirección pide una columna total desnormalizada.
Fase 1 — Expand: añadir la columna, nulable.
Instantáneo, nulable, sin DEFAULT. El código antiguo sigue funcionando: no sabe que la columna existe y no la necesita.
Fase 2 — Doble escritura. Se despliega una versión de la aplicación que, además de crear el pedido y sus líneas, rellena total. Los pedidos nuevos tienen valor; los antiguos siguen a NULL. Ningún cambio de esquema en esta fase — es puro despliegue de código.
Fase 3 — Backfill: rellenar hacia atrás.
Con las herramientas de este módulo, sin subconsultas, en dos pasos:
CREATE TEMP TABLE totales_pedido AS
SELECT lp.pedido_id,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS importe_productos
FROM lineas_pedido AS lp
GROUP BY lp.pedido_id;UPDATE pedidos AS pe
SET total = t.importe_productos + pe.gastos_envio
FROM totales_pedido AS t
WHERE t.pedido_id = pe.id
AND pe.total IS NULL;Fíjate en el AND pe.total IS NULL: hace el UPDATE idempotente (05-03) y, sobre todo, garantiza que el backfill no pisa los valores que la fase 2 ya está escribiendo en tiempo real. Es el detalle que convierte un backfill peligroso en uno seguro.
En una tabla real el backfill se haría por lotes —WHERE total IS NULL AND id BETWEEN 1 AND 10000, repetido— para no mantener una transacción gigante bloqueando filas.
Fase 4 — Cambiar la lectura, y verificar.
Antes de que los informes empiecen a usar la columna nueva, hay que comprobar que coincide con el cálculo en vivo:
SELECT pe.id,
pe.fecha_pedido,
pe.estado,
pe.total AS total_columna,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)
+ pe.gastos_envio AS total_calculado,
pe.total - (ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2)
+ pe.gastos_envio) AS diferencia
FROM pedidos AS pe
JOIN lineas_pedido AS lp ON lp.pedido_id = pe.id
GROUP BY pe.id, pe.fecha_pedido, pe.estado, pe.total, pe.gastos_envio
ORDER BY pe.id
LIMIT 8;| id | fecha_pedido | estado | total_columna | total_calculado | diferencia |
|---|---|---|---|---|---|
| 1 | 2025-03-04 | entregado | 47.05 | 47.05 | 0.00 |
| 2 | 2025-03-12 | entregado | 26.70 | 26.70 | 0.00 |
| 3 | 2025-04-02 | entregado | 34.48 | 34.48 | 0.00 |
| 4 | 2025-04-19 | entregado | 36.70 | 36.70 | 0.00 |
| 5 | 2025-05-07 | entregado | 32.10 | 32.10 | 0.00 |
| 6 | 2025-05-23 | cancelado | 31.70 | 31.70 | 0.00 |
| 7 | 2025-06-11 | entregado | 37.10 | 37.10 | 0.00 |
| 8 | 2025-06-28 | entregado | 74.78 | 74.78 | 0.00 |
(8 primeras de 20 filas.)
Y la comprobación global, que es la que de verdad importa:
SELECT COUNT(*) AS pedidos,
SUM(total) AS suma_columna,
COUNT(*) FILTER (WHERE total IS NULL) AS sin_rellenar
FROM pedidos;| pedidos | suma_columna | sin_rellenar |
|---|---|---|
| 20 | 846.20 | 0 |
846,20 €: exactamente la cifra canónica del módulo 4 (727,95 € de producto + 118,25 € de portes). Veinte pedidos, ninguno sin rellenar. El backfill es correcto.
En esta fase, lo profesional es dejar la comparación funcionando unos días con una alerta: si alguna vez total deja de coincidir con la suma de las líneas, es que la doble escritura tiene un agujero.
Fase 5 — Contract: consolidar y limpiar.
Ahora que todas las filas tienen valor, la columna puede ser obligatoria. Y se retira del código el cálculo antiguo.
Queda un problema abierto, y conviene decirlo con claridad: pedidos.total es un agregado precalculado y hay que mantenerlo sincronizado. Si mañana alguien modifica una línea de pedido con un UPDATE directo, total queda obsoleto y nadie se entera. Las tres soluciones —un trigger que lo recalcule, una vista materializada, o una columna generada si la fórmula viviera en la misma fila— son del módulo 10. Es exactamente la advertencia de 01-05, sección 8: desnormaliza con medidas en la mano, y documenta cómo vas a mantenerlo al día.
La variante: partir nombre y apellidos
El mismo patrón, aplicado a un cambio de forma. Supón que TiendaVerde hubiera nacido con una única columna nombre_completo y quisieras separarla:
| Fase | Acción |
|---|---|
| 1 · Expand | ALTER TABLE clientes ADD COLUMN nombre VARCHAR(60), ADD COLUMN apellidos VARCHAR(90); |
| 2 · Doble escritura | La aplicación rellena las tres columnas en cada alta y en cada modificación |
| 3 · Backfill | UPDATE que parte nombre_completo por el primer espacio (funciones de cadena: módulo 6) |
| 4 · Cambiar la lectura | Los formularios y listados pasan a usar nombre y apellidos; se compara con nombre_completo |
| 5 · Contract | SET NOT NULL en las dos nuevas y DROP COLUMN nombre_completo |
Fíjate en lo que nunca se hace: un RENAME COLUMN de golpe, ni un DROP COLUMN antes de que nadie la lea. Cada fase es reversible y ninguna rompe la versión anterior del código.
- Migraciones versionadas
Todo lo anterior tiene un requisito previo que no es técnico:
Un cambio de esquema no es un comando: es un fichero versionado en el repositorio.
Qué es una migración versionada
Un fichero numerado, con nombre descriptivo, que contiene un cambio de esquema y (idealmente) su reversión:
db/migrations/ ├── V001__esquema_inicial.sql ├── V002__anadir_telefono_clientes.sql ├── V003__anadir_total_pedidos.sql ├── V004__backfill_total_pedidos.sql └── V005__total_pedidos_not_null.sql
Una herramienta de migración mantiene en la propia base de datos una tabla de control con las migraciones ya aplicadas, y al arrancar aplica solo las que faltan, en orden.
Por qué esto es innegociable:
| Sin migraciones versionadas | Con migraciones versionadas |
|---|---|
| Nadie sabe qué esquema tiene cada entorno | El esquema es reproducible desde cero |
| Los cambios no pasan por revisión de código | Cada cambio es una pull request revisable |
| No hay historia: ¿quién añadió esa columna y por qué? | git log responde |
| Desarrollo, pruebas y producción divergen | Los tres aplican la misma secuencia |
| Un despliegue puede olvidar el cambio de base | El despliegue lo aplica automáticamente |
| Reproducir un bug es imposible | Basta con clonar el repositorio |
Herramientas habituales
| Herramienta | Ecosistema | Formato | Reversión |
|---|---|---|---|
| Flyway | Java, JVM, CLI | SQL puro o Java | U (Teams) o manual |
| Liquibase | Java, multiplataforma | XML, YAML, JSON o SQL | Automática en muchos casos |
| Alembic | Python (SQLAlchemy) | Python (upgrade/downgrade) |
Explícita, muy usada |
| Active Record Migrations | Ruby on Rails | Ruby (change o up/down) |
Automática cuando se puede inferir |
| Django migrations | Python (Django) | Python autogenerado desde los modelos | Automática en su mayoría |
| Laravel migrations | PHP | PHP (up/down) |
Explícita |
| Sqitch | Agnóstica, CLI | SQL puro (deploy/revert/verify) | Explícita, con verificación |
| golang-migrate / dbmate | Go, agnósticas | SQL puro (.up.sql / .down.sql) |
Explícita |
Todas resuelven el mismo problema y difieren sobre todo en si el cambio se escribe en SQL o en el lenguaje de la aplicación. Escribirlo en SQL puro (Flyway, Sqitch, golang-migrate) da control total sobre lock_timeout, NOT VALID y CONCURRENTLY; escribirlo en el lenguaje de la aplicación (Alembic, Rails, Django) da portabilidad entre motores y reversión automática, al precio de perder el control fino que necesitan las tablas grandes.
Las cinco reglas de oro
- Una migración = un cambio. Si el fichero hace cinco cosas y la tercera falla en producción, no sabrás en qué estado ha quedado (y en MySQL, además, no podrás deshacerlo).
- Siempre con script de reversión. Aunque tu motor tenga DDL transaccional, escribir el down te obliga a pensar si el cambio es reversible. Muchas veces descubrirás que no lo es — un
DROP COLUMNno se deshace — y eso es información valiosísima antes de aplicarlo. - Probada en un entorno igual al de producción. No en tu portátil con 20 filas: en una copia con volumen realista. Un
ALTER COLUMN TYPEque tarda 40 ms con 20 filas tarda 40 minutos con 40 millones. - Con copia de seguridad previa y verificada. Y verificada significa restaurada alguna vez, no "el cron dice que funciona".
- Nunca a mano en la consola de producción. Ni "solo esta vez", ni "es un cambio pequeño", ni "es urgente". El cambio que no está en el repositorio no existe, y el siguiente entorno que se cree no lo tendrá.
Y un corolario que sale de la fase 2 del expand/contract: las migraciones que rompen la compatibilidad hacia atrás deben partirse en varias. V003 añade la columna, V004 la rellena, V005 la hace obligatoria — cada una desplegable por separado, y entre ellas se despliega el código que las necesita.
- Qué hacer y qué no hacer en producción
Qué NO hacer
| ❌ | Por qué |
|---|---|
ALTER TABLE a mano en la consola de producción |
No queda registro, no pasa por revisión y el siguiente entorno no lo tendrá |
DROP COLUMN de una columna que "parece que no se usa" |
Si te equivocas, el dato no vuelve. Primero deja de leerla durante semanas, luego bórrala |
RENAME COLUMN de golpe |
Rompe todo el código que la nombra en el instante del COMMIT |
ALTER COLUMN TYPE en una tabla grande en horario de trabajo |
Reescribe la tabla con bloqueo exclusivo |
ADD CONSTRAINT sin NOT VALID en una tabla grande |
Escanea la tabla entera con bloqueo exclusivo |
Migrar sin lock_timeout |
Un ALTER de 1 ms puede encolar toda la carga detrás de una consulta larga |
| Un fichero de migración con quince cambios | Si falla el octavo, buena suerte |
| Aplicar la migración y desplegar el código a la vez | Durante el despliegue conviven ambas versiones. Expand/contract existe por eso |
Qué SÍ hacer
| ✅ | Por qué |
|---|---|
| Fichero versionado, revisado en pull request | Historia, revisión y reproducibilidad |
| Copia de seguridad verificada antes | La única red de seguridad real |
| Probarla en una copia con volumen realista | Los tiempos no escalan linealmente en la intuición |
SET LOCAL lock_timeout en cada migración |
Convierte una caída en un reintento |
NOT VALID + VALIDATE CONSTRAINT en tablas grandes |
Protección inmediata sin bloqueo largo |
CREATE INDEX CONCURRENTLY (módulo 8) |
Construye el índice sin bloquear escrituras |
| Backfill por lotes e idempotente | Transacciones cortas, reejecutable sin daño |
| Ventana de mantenimiento para lo que reescribe | Si algo va a tardar, que tarde cuando no molesta |
| Expand/contract para todo cambio incompatible | Cero tiempo de parada |
| Revisión del responsable de la base de datos | Alguien que conozca el volumen real, la carga y las dependencias |
La regla que resume las dos tablas: en producción, la pregunta no es "¿funciona este
ALTER TABLE?", sino "¿qué pasa mientras se ejecuta, y qué pasa si falla a la mitad?". Si no sabes responder a las dos, la migración no está lista.
Errores Comunes y Consejos
- Escribir
ALTER TABLEa mano en producción. El error raíz del que salen casi todos los demás. - Creer que
ADD COLUMNconDEFAULTsiempre reescribe la tabla. Desde PostgreSQL 11 no lo hace, salvo que elDEFAULTsea volátil. - Creer que una operación "instantánea" es inofensiva. Necesita el
ACCESS EXCLUSIVE, y si no lo consigue encola toda la carga detrás.lock_timeout, siempre. RENAMEde golpe. Rompe el código antiguo en el instante delCOMMIT. Expand/contract, o no lo hagas.ALTER COLUMN TYPEsinUSINGcuando la conversión no es automática. ElHINTde PostgreSQL te dice exactamente qué escribir.- Reducir un
VARCHARsin comprobar los datos. PostgreSQL aborta; MySQL en modo relajado trunca en silencio. ADD CONSTRAINTsinNOT VALIDen tablas grandes. Escaneo completo con bloqueo exclusivo.- Esperar que cambiar el
DEFAULTactualice las filas existentes. No lo hace: solo afecta a las inserciones futuras. - Usar
DROP COLUMNcomo borrado seguro de datos sensibles. El dato sigue en disco hasta que la fila se reescriba. - Suponer que el DDL es transaccional en todas partes. En PostgreSQL, SQL Server y SQLite sí; en MySQL y Oracle, no: una migración a medias se queda a medias.
- Backfill en una sola transacción gigante. Bloquea filas, infla el WAL y si falla hay que empezar de cero. Por lotes.
- Backfill que pisa la doble escritura. Añade
WHERE columna IS NULLpara tocar solo lo que falta. - Migraciones sin script de reversión. Escribirlo es la forma más barata de descubrir que el cambio no es reversible.
- Consejo:
\d tablaantes y después de cadaALTER TABLE. Comprobar lo que has hecho cuesta dos segundos. - Consejo: envuelve las migraciones en
BEGIN…COMMITsi tu motor lo permite. En PostgreSQL, quince cambios pasan a ser atómicos. - Consejo: mide el tiempo en una copia con volumen real antes de tocar producción. Es la diferencia entre una ventana de mantenimiento de cinco minutos y una de cinco horas.
Ejercicios
Trabaja sobre la base recién recargada, dentro de BEGIN … ROLLBACK.
Ejercicio 1
TiendaVerde quiere registrar el canal por el que entra cada pedido (web o telefono), un dato que hasta ahora se deducía indirectamente de si empleado_id era NULL.
- Añade a
pedidosuna columnacanal VARCHAR(10), obligatoria, con valor por omisión'web'y protegida con unCHECKcon nombre que solo admita'web'y'telefono'. - Rellena hacia atrás: los pedidos con comercial asignado son
'telefono'; el resto,'web'. - Comprueba el resultado agrupando por canal.
- Responde: ¿por qué el paso 1 no requiere reescribir la tabla? ¿Y qué habría pasado si hubieras puesto el
CHECKantes de rellenar los datos?
Ejercicio 2
Un compañero te pasa este fichero de migración para revisarlo antes de aplicarlo a producción, donde productos tiene 4 millones de filas y lineas_pedido 90 millones:
-- ⚠️ V017__mejoras_catalogo.sql
ALTER TABLE productos RENAME COLUMN nombre TO denominacion;
ALTER TABLE productos ADD COLUMN sku VARCHAR(20) NOT NULL DEFAULT gen_random_uuid()::text;
ALTER TABLE productos ADD CONSTRAINT uq_productos_sku UNIQUE (sku);
ALTER TABLE productos ALTER COLUMN precio TYPE NUMERIC(14,4);
ALTER TABLE lineas_pedido ADD CONSTRAINT chk_lp_importe CHECK (cantidad * precio_unitario >= 0);
ALTER TABLE productos DROP COLUMN coste;- Identifica todos los problemas, indicando para cada uno si es de bloqueo, de compatibilidad, de reversibilidad o de proceso.
- Estima cuáles de las seis operaciones son instantáneas y cuáles no.
- Reescribe la migración como debería estar, partiéndola en los ficheros que haga falta.
Ejercicio 3
Aplica el patrón expand/contract completo para añadir a clientes una columna total_gastado NUMERIC(10,2) que acumule lo que cada cliente ha comprado (producto + portes).
- Escribe las cinco fases, indicando en cada una qué es cambio de esquema, qué es cambio de código y qué es cambio de datos.
- Ejecuta las fases 1, 3 y 5 sobre TiendaVerde (la 2 y la 4 son de aplicación).
- Verifica que la suma de
total_gastadode todos los clientes coincide con el total de pedidos del curso. - Discute: ¿debe la columna ser
NOT NULL? ¿Qué valor tienen los tres clientes sin pedidos, y qué implica eso?
Soluciones
Solución 1
BEGIN;
-- 1) Añadir la columna con su DEFAULT y su CHECK, en una sola sentencia
ALTER TABLE pedidos
ADD COLUMN canal VARCHAR(10) NOT NULL DEFAULT 'web',
ADD CONSTRAINT chk_pedidos_canal CHECK (canal IN ('web', 'telefono'));-- 2) Backfill: los que tienen comercial entraron por teléfono
UPDATE pedidos
SET canal = 'telefono'
WHERE empleado_id IS NOT NULL
AND canal <> 'telefono';-- 3) Comprobación
SELECT canal,
COUNT(*) AS pedidos,
COUNT(empleado_id) AS con_comercial,
COUNT(*) - COUNT(empleado_id) AS sin_comercial,
SUM(gastos_envio) AS portes
FROM pedidos
GROUP BY canal
ORDER BY pedidos DESC, canal;| canal | pedidos | con_comercial | sin_comercial | portes |
|---|---|---|---|---|
| telefono | 10 | 10 | 0 | 72.15 |
| web | 10 | 0 | 10 | 46.10 |
Diez y diez, exactamente el reparto que 01-06 describía: la mitad del canal es web. Y las tres formas de COUNT de 04-04 confirman la coherencia: en el canal telefono los 10 pedidos tienen comercial, en web ninguno. Los portes suman 72,15 € + 46,10 € = 118,25 €, la cifra canónica del módulo 4 — y de paso revelan algo que no se había mirado nunca: el canal telefónico paga bastantes más portes, porque concentra los pedidos a Portugal y Francia.
4. Las dos preguntas.
Por qué no reescribe la tabla: el DEFAULT 'web' es una constante, y desde PostgreSQL 11 eso se guarda en el catálogo y se devuelve al vuelo para las filas antiguas. Si el valor por omisión hubiera sido algo volátil, sí habría reescrito las 20 filas (irrelevante aquí, decisivo con 20 millones).
Qué habría pasado con el CHECK antes de los datos: en este caso concreto, nada malo, porque el DEFAULT 'web' deja todas las filas con un valor válido. Pero si hubiéramos añadido la columna nulable y sin DEFAULT y luego el CHECK, tampoco habría fallado: las 20 filas tendrían NULL, y un CHECK que se evalúa a UNKNOWN se acepta (05-01, apartado 3.5). El CHECK habría pasado sin protestar y con la columna vacía. Es la trampa clásica: el orden importa, pero el CHECK no siempre te avisa de que has hecho las cosas al revés. El NOT NULL sí lo habría hecho.
En una tabla grande el orden correcto y seguro sería: añadir la columna nulable → backfill por lotes → ADD CONSTRAINT ... NOT VALID → VALIDATE CONSTRAINT → SET NOT NULL.
Solución 2
1 y 2. Los problemas, operación por operación:
| # | Operación | Instantánea | Problemas |
|---|---|---|---|
| 1 | RENAME COLUMN nombre TO denominacion |
Sí | Compatibilidad: rompe todo el código que dice nombre en el instante del COMMIT. Necesita expand/contract |
| 2 | ADD COLUMN sku ... DEFAULT gen_random_uuid()::text |
No | Bloqueo: DEFAULT volátil → reescribe 4 millones de filas con ACCESS EXCLUSIVE. Además, un UUID no es un SKU: es un identificador sin significado para una columna que debería tener formato de negocio |
| 3 | ADD CONSTRAINT uq_productos_sku UNIQUE |
No | Bloqueo: construye un índice único sobre 4 millones de filas con bloqueo exclusivo. Debería ser CREATE UNIQUE INDEX CONCURRENTLY + ADD CONSTRAINT ... USING INDEX |
| 4 | ALTER COLUMN precio TYPE NUMERIC(14,4) |
No | Bloqueo: cambiar la escala de un NUMERIC no es binario-compatible → reescribe la tabla y reconstruye índices. Y de paso cambia la semántica del dinero del sistema (01-04) |
| 5 | ADD CONSTRAINT chk_lp_importe sin NOT VALID |
No | Bloqueo: escaneo completo de 90 millones de filas con bloqueo exclusivo. Y la restricción es inútil: cantidad > 0 y precio_unitario >= 0 ya lo garantizan |
| 6 | DROP COLUMN coste |
Sí | Reversibilidad: destruye el dato del que salen todos los márgenes del negocio, sin vuelta atrás |
Y dos problemas de proceso que afectan al fichero entero:
- Seis cambios heterogéneos en una migración. Viola la primera regla de oro: si falla el cuarto, quedas a medias (en PostgreSQL el
ROLLBACKte salva; en MySQL, no). - Sin
lock_timeout. Cualquiera de estas operaciones puede encolar toda la carga detrás de sí.
Estimación de tiempos con esos volúmenes: las operaciones 2, 3 y 4 se cuentan en minutos u horas cada una; la 5, en varios minutos. La migración completa dejaría el catálogo inaccesible durante todo ese tiempo.
3. La versión correcta, partida en ficheros:
-- V017__anadir_sku_productos.sql
-- Fase EXPAND. Instantánea: columna nulable, sin DEFAULT.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE productos ADD COLUMN sku VARCHAR(20);
COMMIT;-- V018__backfill_sku_productos.sql
-- Por lotes, idempotente. Se ejecuta fuera de horario punta.
-- (El generador real de SKU lo aporta la aplicación; aquí, un ejemplo)
UPDATE productos
SET sku = 'TV-' || LPAD(id::text, 6, '0')
WHERE sku IS NULL
AND id BETWEEN :desde AND :hasta;-- V019__sku_unico_y_obligatorio.sql
-- El índice se construye SIN bloquear escrituras (módulo 8).
CREATE UNIQUE INDEX CONCURRENTLY uq_productos_sku_idx ON productos (sku);
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE productos
ADD CONSTRAINT uq_productos_sku UNIQUE USING INDEX uq_productos_sku_idx;
ALTER TABLE productos ALTER COLUMN sku SET NOT NULL;
COMMIT;Y las tres operaciones restantes:
| Operación | Veredicto |
|---|---|
RENAME COLUMN nombre TO denominacion |
Rechazada tal cual. Si de verdad hace falta, expand/contract en cuatro migraciones y varios despliegues. Preguntar antes si el nombre nuevo aporta algo |
ALTER COLUMN precio TYPE NUMERIC(14,4) |
Rechazada. Cambia la semántica del dinero de todo el sistema. Si hiciera falta más precisión para casos concretos, es una columna nueva, no un cambio de tipo |
DROP COLUMN coste |
Rechazada. Antes: comprobar que ninguna consulta la usa, dejar de leerla durante semanas, y solo entonces plantear el borrado en su propia migración con copia de seguridad verificada |
ADD CONSTRAINT chk_lp_importe |
Rechazada por innecesaria. cantidad > 0 y precio_unitario >= 0 ya lo garantizan. Y si aun así se quisiera, NOT VALID + VALIDATE |
La lección del ejercicio: la mayor parte de una revisión de migraciones consiste en decir que no. De seis operaciones, una está bien planteada y cinco no deberían aplicarse tal como están.
Solución 3
1. Las cinco fases:
| Fase | Qué es | Acción |
|---|---|---|
| 1 · Expand | Esquema | ALTER TABLE clientes ADD COLUMN total_gastado NUMERIC(10,2); — nulable, instantáneo |
| 2 · Doble escritura | Código | La aplicación suma el total del pedido a clientes.total_gastado al confirmar cada compra |
| 3 · Backfill | Datos | Rellenar hacia atrás con el histórico, por lotes, solo donde total_gastado IS NULL |
| 4 · Cambiar la lectura | Código | Los informes leen la columna; se compara con el cálculo en vivo durante unos días |
| 5 · Contract | Esquema | SET DEFAULT 0 y SET NOT NULL; se retira el cálculo antiguo del código |
2. Ejecución de las fases 1, 3 y 5:
La tentación es hacerlo de una sentencia, uniendo clientes, pedidos y lineas_pedido:
-- ⚠️ INCORRECTA: el error de 04-04, sección 11
SELECT pe.cliente_id,
SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento))
+ SUM(pe.gastos_envio) AS total
FROM lineas_pedido AS lp
JOIN pedidos AS pe ON lp.pedido_id = pe.id
GROUP BY pe.cliente_id;gastos_envio vive en pedidos, y tras el JOIN cada pedido aparece tantas veces como líneas tenga: los portes se multiplicarían y la suma total daría 278,70 € en lugar de 118,25 €. Es exactamente el error que el módulo 3 avisó tres veces y el 4 liquidó.
La forma correcta es agregar en dos etapas, reutilizando la tabla totales_pedido del apartado 10:
CREATE TEMP TABLE totales_pedido AS
SELECT lp.pedido_id,
ROUND(SUM(lp.cantidad * lp.precio_unitario * (1 - lp.descuento)), 2) AS productos
FROM lineas_pedido AS lp
GROUP BY lp.pedido_id;CREATE TEMP TABLE gasto_cliente AS
SELECT pe.cliente_id,
ROUND(SUM(t.productos + pe.gastos_envio), 2) AS total
FROM pedidos AS pe
JOIN totales_pedido AS t ON t.pedido_id = pe.id
GROUP BY pe.cliente_id;UPDATE clientes AS c
SET total_gastado = g.total
FROM gasto_cliente AS g
WHERE g.cliente_id = c.id
AND c.total_gastado IS NULL;SELECT c.id,
c.nombre || ' ' || c.apellidos AS cliente,
c.pais,
c.total_gastado
FROM clientes AS c
ORDER BY c.total_gastado DESC NULLS LAST, c.id;| id | cliente | pais | total_gastado |
|---|---|---|---|
| 7 | Sofia Moreira Costa | Portugal | 131.68 |
| 1 | Lucía Martínez Soler | España | 112.55 |
| 9 | Camille Dubois | Francia | 95.87 |
| 10 | Julien Moreau | Francia | 79.40 |
| 4 | Javier Ortega Ruiz | España | 72.83 |
| 6 | Pau Llorens Vidal | España | 68.78 |
| 5 | Ana Belmonte Roca | España | 64.75 |
| 2 | Carlos Ferrer Ibáñez | España | 59.46 |
| 8 | Tiago Almeida Nunes | Portugal | 54.50 |
| 12 | Diego Ramos Herrera | España | 36.65 |
| 11 | Elena Navarro Puig | España | 35.25 |
| 3 | Marta Sanchis Gil | España | 34.48 |
| 13 | Núria Bosch Ferrer | España | (null) |
| 14 | Hugo Iglesias Pardo | España | (null) |
| 15 | Inés Carrasco Vega | España | (null) |
Sofia Moreira Costa encabeza el ranking con 131,68 € en dos pedidos, por delante de Lucía, que ha hecho tres. Y los tres últimos, con NULL, son los clientes sin pedidos de 01-06.
3. Verificación:
SELECT COUNT(*) AS clientes,
COUNT(total_gastado) AS con_compras,
COUNT(*) - COUNT(total_gastado) AS sin_compras,
SUM(total_gastado) AS total
FROM clientes;| clientes | con_compras | sin_compras | total |
|---|---|---|---|
| 15 | 12 | 3 | 846.20 |
846,20 €: la cifra canónica del curso (727,95 € de producto + 118,25 € de portes). Doce clientes con compras y tres sin ninguna, exactamente los huecos deliberados de 01-06. Las tres formas de COUNT de 04-04 vuelven a hacer todo el trabajo de verificación.
-- FASE 5 · CONTRACT
ALTER TABLE clientes
ALTER COLUMN total_gastado SET DEFAULT 0;
UPDATE clientes SET total_gastado = 0 WHERE total_gastado IS NULL;ALTER TABLE clientes
ALTER COLUMN total_gastado SET NOT NULL,
ADD CONSTRAINT chk_clientes_total_gastado CHECK (total_gastado >= 0);4. La discusión. ¿Debe ser NOT NULL?
NULL para quien no ha comprado |
0 para quien no ha comprado |
|
|---|---|---|
| Semántica | "No ha comprado nunca" se distingue de "compró y devolvió todo" | Se confunden los dos casos |
| Consultas | AVG(total_gastado) ignora a los tres, dando la media de compradores |
AVG los incluye y baja la media |
| Ordenación | Necesita NULLS LAST explícito |
Ordena solo |
| Aritmética | total_gastado + 10 da NULL |
Funciona |
| Fidelidad al modelo | Mayor | Menor |
No hay respuesta universal, y esa es la respuesta correcta. Depende de si "no ha comprado" y "ha comprado 0 €" son el mismo hecho de negocio. En TiendaVerde no lo son: Núria, Hugo e Inés nunca han pedido nada, y esa es información. Si se quiere NOT NULL por comodidad aritmética, hay que documentar que el 0 significa dos cosas y añadir una columna pedidos_realizados INTEGER NOT NULL DEFAULT 0 que sí las distinga.
Es exactamente la discusión de 04-03, sección 11 —diseñar con NULL, con centinelas o con NOT NULL— y la prueba de que una decisión de esquema nunca es solo técnica.
Y el problema abierto, el mismo que con pedidos.total: esta columna es un agregado precalculado y hay que mantenerla sincronizada. Cada pedido nuevo, cada línea modificada y cada devolución la dejan obsoleta. La solución —un trigger, o una vista materializada que se refresque— es del módulo 10. Mientras no exista, la columna es una bomba de relojería, y por eso la fase 2 del patrón (la doble escritura) no es opcional: es la única que evita que el dato se pudra.
Conclusión del módulo
ALTER TABLE cierra el módulo 5 y con él, tu capacidad de operar sobre una base de datos completa:
- Las operaciones de
ALTER TABLE:ADD COLUMN(instantánea, incluso conDEFAULTconstante desde PostgreSQL 11, salvo que sea volátil),DROP COLUMN(instantánea, irreversible y no un borrado seguro),RENAME(instantánea y la más peligrosa, porque rompe el código antiguo en el acto),ALTER COLUMN TYPEconUSINGpara las conversiones no triviales,SET/DROP NOT NULL,SET/DROP DEFAULT—que no toca las filas existentes—, yADD/DROP CONSTRAINT, donde se cobra la insistencia de 05-01 en nombrarlas. NOT VALID+VALIDATE CONSTRAINT: protección inmediata para las filas nuevas y comprobación del histórico sin bloquear lecturas ni escrituras. La técnica estándar en tablas grandes.- Qué bloquea y qué no, con la tabla de niveles; y el detalle que de verdad tumba producción: una operación instantánea que no consigue el
ACCESS EXCLUSIVEencola toda la carga detrás de sí.SET LOCAL lock_timeoutes la protección. - DDL transaccional: en PostgreSQL, SQL Server y SQLite un
ALTER TABLEse puede deshacer conROLLBACKy una migración de quince pasos es atómica; en MySQL y Oracle no, y una migración que falla a la mitad se queda a la mitad. - Expand/contract: añadir lo nuevo → escribir en ambos → rellenar hacia atrás → cambiar la lectura → eliminar lo viejo. Cinco fases en las que nunca existe un estado que rompa la aplicación. Demostrado con
pedidos.total, cuyo backfill idempotente (WHERE total IS NULL) cuadró en los 846,20 € canónicos del curso. - Migraciones versionadas: un fichero numerado por cambio, en el repositorio, revisado como código, aplicado por una herramienta (Flyway, Liquibase, Alembic, Rails, Django, Laravel, Sqitch). Y las cinco reglas de oro: una migración = un cambio, siempre con reversión, probada con volumen realista, con copia de seguridad verificada, y jamás a mano en producción.
Y con esto se cierra el módulo 5. Repasa lo que has ganado en seis lecciones. Sabes crear tablas con CREATE TABLE y declarar las seis restricciones que llevabas cinco módulos leyendo, entendiendo por qué CHECK acepta los nulos, por qué UNIQUE admite varios y por qué GENERATED BY DEFAULT es lo que obliga a aquel bloque de setval. Sabes insertar con INSERT, listando siempre las columnas, en lotes, y recuperando con RETURNING el id que necesitas para colgar las líneas de un pedido. Sabes modificar con UPDATE, con un protocolo de cinco pasos y una transacción abierta, entendiendo que todas las asignaciones se evalúan sobre la fila antigua y que precio * 1.05 no es idempotente. Sabes borrar con DELETE, distinguirlo de TRUNCATE, predecir qué se lleva por delante cada ON DELETE, y —lo más importante— decidir si hay que borrar, porque activo = FALSE existe justo para no hacerlo. Sabes fusionar con ON CONFLICT y con MERGE, y por qué mirar-y-luego-decidir nunca es seguro. Y sabes cambiar el esquema sin tumbar el servicio.
Ha cambiado algo más que el repertorio de instrucciones: ha cambiado el nivel de responsabilidad. En los módulos 2 a 4, un error tuyo devolvía un número equivocado. Desde este módulo, un error tuyo modifica datos reales. Por eso aquí has aprendido tantos hábitos como sintaxis: escribir el SELECT antes que el UPDATE, contar las filas, abrir la transacción, comprobar el RETURNING, hacer copia antes, poner lock_timeout, versionar la migración. Ninguno de esos hábitos aparece en la referencia del lenguaje, y todos separan a quien lleva años tocando bases de datos de quien lleva un mes.
Con TiendaVerde ya puedes hacerlo todo: consultarla, cruzarla, agregarla, poblarla, corregirla y hacerla evolucionar. Lo que aún no puedes es transformar y presentar lo que sacas de ella. Sigues devolviendo nombre y apellidos en dos columnas cuando quieres una; sigues escribiendo ROUND(...) a mano sin conocer sus hermanas; sigues sin poder extraer el año de una fecha, calcular la antigüedad de un cliente, poner un texto en mayúsculas, convertir un tipo en otro de forma explícita, sustituir un NULL por un valor legible, ni clasificar filas en categorías según una condición. En el módulo 6, Funciones, llegan las herramientas que faltan: funciones de cadena para componer y limpiar texto, funciones numéricas para redondear y calcular con criterio, funciones de fecha y hora para responder por fin a "¿cuántos días tardó ese pedido?", CAST y COALESCE para convertir tipos y domar los nulos que llevas dos módulos esquivando, y CASE para meter lógica condicional dentro de una consulta. A partir de ahí, tus consultas dejarán de devolver datos y empezarán a devolver respuestas.
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
