En una aplicación con tráfico real no se puede ejecutar ALTER TABLE sobre una tabla de millones de filas y esperar que todo funcione. Cualquier modificación de esquema bloquea, en mayor o menor medida, las operaciones sobre la tabla. La solución profesional es el patrón expand-contract: dividir cada cambio en una secuencia de pasos donde la base de datos siempre soporta tanto la versión anterior como la nueva del código.
El problema con ALTER TABLE directo
PostgreSQL es relativamente amable con muchas operaciones DDL, pero hay trampas serias:
ALTER TABLE ADD COLUMN ... DEFAULT valor: hasta PostgreSQL 11 reescribía toda la tabla. Desde la 11, si el default es constante, es instantáneo. Si es una función o un valor calculado, sigue reescribiendo.ALTER TABLE DROP COLUMN: rápido (solo marca la columna como dropped), pero deja bloat que solo VACUUM FULL libera.ALTER TABLE ALTER COLUMN ... TYPE: reescribe la tabla completa salvo casos triviales.ALTER TABLE ADD CONSTRAINT NOT NULL: requiere validar todas las filas; bloquea conACCESS EXCLUSIVEen versiones antiguas.
En tablas pequeñas (menos de 100k filas) los
ALTERdirectos son aceptables. En tablas grandes, cualquier reescritura puede tardar minutos u horas durante los cuales nadie puede escribir.
El patrón expand-contract: cuatro fases
El patrón divide cualquier cambio en cuatro fases secuenciales:
- Expand: añadir lo nuevo manteniendo lo antiguo. La base de datos sigue compatible con código viejo y nuevo.
- Migrate: actualizar el código de aplicación (despliegue gradual).
- Backfill: poblar los nuevos datos a partir de los antiguos en chunks.
- Contract: eliminar lo viejo cuando ya nada lo usa.
Cada fase se realiza con un cambio mínimo, no bloqueante, y se valida antes de pasar a la siguiente.
Caso 1: añadir una columna NOT NULL
Operación aparentemente simple que en una tabla grande puede ser dolorosa.
Mal enfoque (single shot):
ALTER TABLE pedidos ADD COLUMN cliente_email TEXT NOT NULL DEFAULT '';
-- Reescribe toda la tabla, bloquea
Buen enfoque (expand-contract):
-- Fase 1: expand - anyadir columna nullable, cero riesgo
ALTER TABLE pedidos ADD COLUMN cliente_email TEXT;
-- Fase 2: migrate - desplegar codigo que escribe la nueva columna
-- (todos los INSERT y UPDATE incluyen cliente_email a partir de ahora)
-- Fase 3: backfill - rellenar filas viejas en chunks
DO $$
DECLARE
rows_updated INT;
BEGIN
LOOP
UPDATE pedidos
SET cliente_email = (SELECT email FROM clientes WHERE id = pedidos.cliente_id)
WHERE cliente_email IS NULL
AND id IN (SELECT id FROM pedidos WHERE cliente_email IS NULL LIMIT 5000);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
COMMIT;
PERFORM pg_sleep(0.1);
END LOOP;
END $$;
-- Fase 4: contract - poner NOT NULL ahora que todas las filas tienen valor
ALTER TABLE pedidos ALTER COLUMN cliente_email SET NOT NULL;
El backfill en chunks de 5000 filas con sleep entre rondas evita generar mucho WAL de golpe y deja respirar al autovacuum.
Caso 2: renombrar una columna
ALTER TABLE RENAME COLUMN es rápido pero rompe el código que use el nombre viejo. La estrategia correcta:
-- Fase 1: expand - anyadir columna nueva con el nuevo nombre
ALTER TABLE pedidos ADD COLUMN total_iva NUMERIC(12,2);
-- Trigger que sincroniza ambos campos
CREATE OR REPLACE FUNCTION sync_total_columns()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.total_iva IS DISTINCT FROM OLD.total_iva THEN
NEW.total = NEW.total_iva;
ELSIF NEW.total IS DISTINCT FROM OLD.total THEN
NEW.total_iva = NEW.total;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_pedidos_total
BEFORE UPDATE ON pedidos
FOR EACH ROW EXECUTE FUNCTION sync_total_columns();
-- Fase 2: backfill
UPDATE pedidos SET total_iva = total WHERE total_iva IS NULL;
-- Fase 3: migrate - desplegar codigo que lee y escribe total_iva
-- Fase 4: contract - eliminar columna vieja
ALTER TABLE pedidos DROP COLUMN total;
DROP TRIGGER trg_sync_pedidos_total ON pedidos;
DROP FUNCTION sync_total_columns;
Cada fase es individualmente segura y reversible. El despliegue del código entre fases puede ser gradual (canary, blue-green) sin riesgo.
Caso 3: cambiar el tipo de una columna
Cambiar INT a BIGINT, por ejemplo, es una operación común cuando una tabla se acerca al límite de 2.000 millones. Aplicado directamente, requiere reescribir la tabla:
-- Mal: bloquea
ALTER TABLE eventos ALTER COLUMN id TYPE BIGINT;
Mejor con expand-contract:
-- Fase 1: expand - columna nueva del tipo destino
ALTER TABLE eventos ADD COLUMN id_new BIGINT;
CREATE UNIQUE INDEX CONCURRENTLY idx_eventos_id_new ON eventos(id_new);
-- Trigger que mantiene id_new sincronizado
CREATE OR REPLACE FUNCTION fill_id_new()
RETURNS TRIGGER AS $$
BEGIN
NEW.id_new = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_fill_id_new BEFORE INSERT OR UPDATE ON eventos
FOR EACH ROW EXECUTE FUNCTION fill_id_new();
-- Fase 2: backfill
UPDATE eventos SET id_new = id WHERE id_new IS NULL;
-- Fase 3: migrate - aplicacion lee id_new
-- Fase 4: contract - swap
BEGIN;
ALTER TABLE eventos DROP CONSTRAINT eventos_pkey;
ALTER TABLE eventos DROP COLUMN id;
ALTER TABLE eventos RENAME COLUMN id_new TO id;
ALTER TABLE eventos ADD PRIMARY KEY (id);
COMMIT;
La fase contract requiere un breve lock para el swap, pero es rápido y predecible.
Crear índices sin bloqueo: CONCURRENTLY
CREATE INDEX adquiere un lock que bloquea las escrituras sobre la tabla. En tablas pequeñas son segundos; en tablas grandes pueden ser horas. La solución es la opción CONCURRENTLY:
CREATE INDEX CONCURRENTLY idx_pedidos_cliente_creado
ON pedidos (cliente_id, creado_en);
Diferencias importantes:
- No bloquea lecturas ni escrituras durante la creación.
- Tarda más (hace dos pasadas sobre la tabla).
- No se puede ejecutar dentro de una transacción.
- Si falla por algún motivo, queda un índice inválido que hay que limpiar:
DROP INDEX CONCURRENTLY idx_pedidos_cliente_creado;
-- y reintentar
CREATE INDEX CONCURRENTLY idx_pedidos_cliente_creado
ON pedidos (cliente_id, creado_en);
Lo mismo aplica para REINDEX:
REINDEX INDEX CONCURRENTLY idx_pedidos_cliente_creado;
Añadir constraints NOT NULL sin bloqueo (PG 12+)
Desde PostgreSQL 12, una constraint NOT NULL se puede añadir en dos pasos sin bloquear:
-- Paso 1: marcar como NOT NULL pero NOT VALID
ALTER TABLE pedidos
ADD CONSTRAINT pedidos_email_not_null CHECK (cliente_email IS NOT NULL) NOT VALID;
-- Paso 2: validar las filas existentes (no bloquea escrituras)
ALTER TABLE pedidos VALIDATE CONSTRAINT pedidos_email_not_null;
-- Paso 3: convertir el CHECK en NOT NULL nativo (rapido)
ALTER TABLE pedidos ALTER COLUMN cliente_email SET NOT NULL;
ALTER TABLE pedidos DROP CONSTRAINT pedidos_email_not_null;
El VALIDATE recorre la tabla pero adquiere solo SHARE UPDATE EXCLUSIVE, compatible con DML normal.
pg_repack y vacuum full sin bloqueo
Visto en la lección de vacuum: pg_repack reorganiza tablas e índices sin bloquear:
pg_repack -d produccion -t pedidos
pg_repack -d produccion -t pedidos --only-indexes
Útil tras una migración con muchos UPDATE para devolver el espacio liberado al sistema operativo y reducir el bloat.
MySQL: pt-online-schema-change y gh-ost
En MySQL las herramientas equivalentes son pt-online-schema-change (de Percona Toolkit) y gh-ost (de GitHub):
- pt-online-schema-change: usa triggers para capturar cambios mientras se copia la tabla. Mature pero la carga sobre el primario es notable.
- gh-ost: lee del binlog en lugar de usar triggers. Menor impacto, más predecible.
# pt-online-schema-change
pt-online-schema-change --alter "ADD COLUMN nuevo_campo VARCHAR(100)" \
D=produccion,t=pedidos --execute
# gh-ost
gh-ost --user=root --password=... --database=produccion \
--table=pedidos --alter="ADD COLUMN nuevo_campo VARCHAR(100)" --execute
Para PostgreSQL,
pg_repackcubre los casos de reorganización; los cambios de esquema se hacen con expand-contract manualmente.
Versionado de esquema
Las migraciones deben versionarse en el repositorio igual que el código. Herramientas habituales:
- Flyway: scripts SQL numerados, idempotentes, en orden estricto.
- Liquibase: descripción declarativa en YAML/XML, soporta rollback.
- Alembic (Python): integración con SQLAlchemy.
- Sqitch: dependencias entre migraciones, sin numeración rígida.
Buenas prácticas:
- Cada migración hace un solo cambio.
- Las migraciones son idempotentes (
IF NOT EXISTS,IF EXISTS). - Las migraciones de aplicación se prueban en CI con un dump real de producción.
- Las migraciones expand-contract se reparten en varias releases del código.
Lista de chequeo antes de cada migración
Cualquier migración productiva debería responder a estas preguntas:
- ¿Bloquea la tabla? Estimar duración.
- ¿Se puede revertir si falla?
- ¿Es compatible con la versión anterior del código (rolling deploy)?
- ¿Cuánto WAL genera? ¿Hay espacio?
- ¿Necesita backfill? ¿Cuánto tarda en chunks razonables?
- ¿Está documentada en el sistema de cambios?
flowchart LR
A[Cambio deseado] --> B{Tabla pequena?}
B -->|Si| C[ALTER directo en mantenimiento]
B -->|No| D[Expand: anyadir nuevo nullable]
D --> E[Migrate: codigo nuevo]
E --> F[Backfill: chunks pequenos]
F --> G[Contract: NOT NULL, drop columna vieja]
G --> H[pg_repack si es necesario]
Las migraciones sin downtime son una disciplina, no una técnica única. Cada cambio se descompone en pasos pequeños, validables y reversibles. La inversión inicial en planificación se recupera al evitar maintenance windows largos y al permitir despliegues continuos sin interrumpir el servicio.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, SQL es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de SQL
Explora más contenido relacionado con SQL y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Aplicar el patrón expand-contract para añadir, renombrar y eliminar columnas. Backfillear tablas grandes en chunks evitando bloqueos largos. Crear índices CONCURRENTLY para no bloquear escrituras. Usar pg_repack para reorganizar tablas online. Conocer pt-online-schema-change y gh-ost para entornos MySQL.
Cursos que incluyen esta lección
Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje