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 TABLE es una operación destructiva e irreversible en sus efectos sobre los datos. Un DROP COLUMN elimina una columna entera y todo su contenido; un ALTER COLUMN TYPE puede truncar valores; un ADD CONSTRAINT puede 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 BEGINROLLBACK: 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

  1. ALTER TABLE: el mapa de operaciones
  2. ADD COLUMN, con y sin DEFAULT
  3. DROP COLUMN
  4. RENAME COLUMN y RENAME TO
  5. ALTER COLUMN TYPE y la cláusula USING
  6. SET / DROP NOT NULL y SET / DROP DEFAULT
  7. ADD / DROP CONSTRAINT, y la técnica NOT VALID + VALIDATE
  8. Qué bloquea la tabla y qué no
  9. DDL transaccional: PostgreSQL frente a MySQL
  10. El patrón expand/contract
  11. Migraciones versionadas
  12. Qué hacer y qué no hacer en producción
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión del módulo

  1. ALTER TABLE: el mapa de operaciones

ALTER TABLE [IF EXISTS] nombre_tabla acción [, acción ...];

Las 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;
ALTER TABLE
tiendaverde=> \d clientes
                            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 | false

Aviso sobre los objetos de esta lección. clientes.telefono, clientes.newsletter y pedidos.total son ejemplos puntuales: no forman parte del esquema canónico de TiendaVerde y no aparecerán en los módulos siguientes. Recarga tiendaverde.sql al terminar.

  1. 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

ALTER TABLE clientes ADD COLUMN telefono VARCHAR(20);
ALTER TABLE

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.

SELECT id, nombre, apellidos, telefono FROM clientes ORDER BY id LIMIT 3;
id nombre apellidos telefono
1 Lucía Martínez Soler (null)
2 Carlos Ferrer Ibáñez (null)
3 Marta Sanchis Gil (null)

Con DEFAULT

ALTER TABLE clientes
ADD COLUMN newsletter BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE
SELECT COUNT(*) FILTER (WHERE NOT newsletter) AS sin_newsletter, COUNT(*) AS total
FROM clientes;
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 DEFAULT constante 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()) , fila a fila

Nota de dialecto: MySQL 8 con InnoDB permite ADD COLUMN con ALGORITHM=INSTANT en 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 un DEFAULT no constante. SQL Server distingue entre añadir una columna nulable (instantáneo) y una NOT NULL con DEFAULT (instantáneo desde 2012 Enterprise, reescritura en el resto de ediciones).

  1. DROP COLUMN

ALTER TABLE clientes DROP COLUMN newsletter;
ALTER TABLE

Tambié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:

  1. El dato sigue físicamente en disco hasta que se reescriba la fila. Si la columna contenía información sensible, un DROP COLUMN no es un borrado seguro.
  2. 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:

ALTER TABLE productos DROP COLUMN precio;
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:

ALTER TABLE productos DROP COLUMN precio CASCADE;
NOTICE:  drop cascades to constraint productos_precio_check on table productos
ALTER TABLE

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.

  1. RENAME COLUMN y RENAME TO

ALTER TABLE resenas RENAME COLUMN comentario TO texto;
ALTER TABLE resenas RENAME TO valoraciones;
ALTER TABLE
ALTER TABLE

Ambas 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:

SELECT comentario FROM resenas;
ERROR:  relation "resenas" does not exist
LINE 1: SELECT comentario FROM resenas;
                               ^

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 RENAME en 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 TABLE valoraciones RENAME TO resenas;
ALTER TABLE resenas RENAME COLUMN texto TO comentario;

  1. ALTER COLUMN TYPE y la cláusula USING

ALTER TABLE productos ALTER COLUMN precio TYPE NUMERIC(12,2);
ALTER TABLE

Ampliar 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:

ALTER TABLE clientes ALTER COLUMN nombre TYPE VARCHAR(5);
ERROR:  value too long for type character varying(5)

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. Con sql_mode en STRICT_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');
CREATE TABLE
INSERT 0 3

Un intento directo de convertir el texto a fecha:

ALTER TABLE pedidos_importados ALTER COLUMN fecha_texto TYPE DATE;
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);
ALTER TABLE
SELECT id, referencia, fecha_texto, importe_texto FROM pedidos_importados ORDER BY id;
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

  1. 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;
ERROR:  column "ciudad" of relation "clientes" contains null values

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:

ALTER TABLE pedidos ALTER COLUMN empleado_id SET NOT NULL;
ERROR:  column "empleado_id" of relation "pedidos" contains null values

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:

ALTER TABLE productos ALTER COLUMN precio DROP NOT NULL;
ALTER TABLE
ALTER TABLE productos ALTER COLUMN precio SET NOT NULL;   -- lo dejamos como estaba
ALTER TABLE

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;
ALTER TABLE
ALTER TABLE
ALTER TABLE
ALTER TABLE

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.

  1. ADD / DROP CONSTRAINT, y la técnica NOT VALID + VALIDATE

Aquí 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.

ALTER TABLE productos
ADD CONSTRAINT chk_productos_margen CHECK (coste IS NULL OR coste <= precio);
ALTER TABLE

Funciona porque ninguno de los 20 productos tiene un coste superior a su precio. Si lo hubiera, PostgreSQL rechazaría el ALTER TABLE entero:

ERROR:  check constraint "chk_productos_margen" of relation "productos" is violated by some row

Quitarla:

ALTER TABLE productos DROP CONSTRAINT chk_productos_margen;
ALTER TABLE

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);
ALTER TABLE
ALTER TABLE
ALTER TABLE

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;
ALTER TABLE
-- Fase 2: validar. Puede tardar, pero NO bloquea lecturas ni escrituras.
ALTER TABLE productos VALIDATE CONSTRAINT chk_productos_margen;
ALTER TABLE

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

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:

ALTER TABLE productos DROP CONSTRAINT chk_productos_margen;

  1. 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 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 Proporcional
ALTER COLUMN TYPE (binario-compatible) ACCESS EXCLUSIVE No No Instantánea
ALTER COLUMN TYPE (resto) ACCESS EXCLUSIVE Proporcional
ADD CONSTRAINT CHECK ACCESS EXCLUSIVE No Proporcional
ADD CONSTRAINT CHECK ... NOT VALID ACCESS EXCLUSIVE No No Instantánea
ADD FOREIGN KEY ACCESS EXCLUSIVE en ambas tablas No Proporcional
ADD FOREIGN KEY ... NOT VALID SHARE ROW EXCLUSIVE No No Instantánea
VALIDATE CONSTRAINT SHARE UPDATE EXCLUSIVE No 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 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:

BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE clientes ADD COLUMN telefono VARCHAR(20);
COMMIT;

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.

  1. 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
ROLLBACK;
ROLLBACK
SELECT id, nombre, telefono FROM clientes LIMIT 1;
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 BEGINCOMMIT 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 No No
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 No 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.

  1. 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.

ALTER TABLE pedidos ADD COLUMN total NUMERIC(10,2);
ALTER TABLE

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;
SELECT 20
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;
UPDATE 20

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 lotesWHERE 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.

ALTER TABLE pedidos ALTER COLUMN total SET NOT NULL;
ALTER TABLE

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.

  1. 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

  1. 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).
  2. 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 COLUMN no se deshace — y eso es información valiosísima antes de aplicarlo.
  3. 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 TYPE que tarda 40 ms con 20 filas tarda 40 minutos con 40 millones.
  4. Con copia de seguridad previa y verificada. Y verificada significa restaurada alguna vez, no "el cron dice que funciona".
  5. 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.

  1. 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 TABLE a mano en producción. El error raíz del que salen casi todos los demás.
  • Creer que ADD COLUMN con DEFAULT siempre reescribe la tabla. Desde PostgreSQL 11 no lo hace, salvo que el DEFAULT sea 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.
  • RENAME de golpe. Rompe el código antiguo en el instante del COMMIT. Expand/contract, o no lo hagas.
  • ALTER COLUMN TYPE sin USING cuando la conversión no es automática. El HINT de PostgreSQL te dice exactamente qué escribir.
  • Reducir un VARCHAR sin comprobar los datos. PostgreSQL aborta; MySQL en modo relajado trunca en silencio.
  • ADD CONSTRAINT sin NOT VALID en tablas grandes. Escaneo completo con bloqueo exclusivo.
  • Esperar que cambiar el DEFAULT actualice las filas existentes. No lo hace: solo afecta a las inserciones futuras.
  • Usar DROP COLUMN como 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 NULL para 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 tabla antes y después de cada ALTER TABLE. Comprobar lo que has hecho cuesta dos segundos.
  • Consejo: envuelve las migraciones en BEGINCOMMIT si 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 BEGINROLLBACK.

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.

  1. Añade a pedidos una columna canal VARCHAR(10), obligatoria, con valor por omisión 'web' y protegida con un CHECK con nombre que solo admita 'web' y 'telefono'.
  2. Rellena hacia atrás: los pedidos con comercial asignado son 'telefono'; el resto, 'web'.
  3. Comprueba el resultado agrupando por canal.
  4. Responde: ¿por qué el paso 1 no requiere reescribir la tabla? ¿Y qué habría pasado si hubieras puesto el CHECK antes 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;
  1. Identifica todos los problemas, indicando para cada uno si es de bloqueo, de compatibilidad, de reversibilidad o de proceso.
  2. Estima cuáles de las seis operaciones son instantáneas y cuáles no.
  3. 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).

  1. 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.
  2. Ejecuta las fases 1, 3 y 5 sobre TiendaVerde (la 2 y la 4 son de aplicación).
  3. Verifica que la suma de total_gastado de todos los clientes coincide con el total de pedidos del curso.
  4. 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'));
ALTER TABLE
-- 2) Backfill: los que tienen comercial entraron por teléfono
UPDATE pedidos
SET    canal = 'telefono'
WHERE  empleado_id IS NOT NULL
  AND  canal <> 'telefono';
UPDATE 10
-- 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
COMMIT;

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 VALIDVALIDATE CONSTRAINTSET 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 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 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 ROLLBACK te 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:

BEGIN;

-- FASE 1 · EXPAND
ALTER TABLE clientes ADD COLUMN total_gastado NUMERIC(10,2);
ALTER TABLE

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;
SELECT 20
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;
SELECT 12
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;
UPDATE 12
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
UPDATE 3
ALTER TABLE clientes
    ALTER COLUMN total_gastado SET NOT NULL,
    ADD CONSTRAINT chk_clientes_total_gastado CHECK (total_gastado >= 0);
ALTER TABLE
COMMIT;

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 con DEFAULT constante 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 TYPE con USING para las conversiones no triviales, SET/DROP NOT NULL, SET/DROP DEFAULT —que no toca las filas existentes—, y ADD/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 EXCLUSIVE encola toda la carga detrás de sí. SET LOCAL lock_timeout es la protección.
  • DDL transaccional: en PostgreSQL, SQL Server y SQLite un ALTER TABLE se puede deshacer con ROLLBACK y 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

Módulo 2: Consultas básicas de SQL

Módulo 3: Trabajando con múltiples tablas

Módulo 4: Filtrado avanzado de datos

Módulo 5: Manipulación de datos

Módulo 6: Funciones avanzadas de SQL

Módulo 7: Subconsultas y consultas anidadas

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

Módulo 9: Transacciones y concurrencia

Módulo 10: Temas avanzados

Módulo 11: SQL en la práctica

Módulo 12: Proyecto final

© Copyright 2026. Todos los derechos reservados