Migraciones de esquema y expand-contract en PostgreSQL | Nicolás Garzón
Una migración de producción no es solo DDL correcto: debe convivir temporalmente con aplicaciones viejas y nuevas, controlar locks, backfills, WAL y una ruta de recuperación.
El cambio seguro suele seguir expand → migrate → contract :
Texto
Copiar expand: añadir forma compatible
migrate: mover lecturas/escrituras y datos
contract: retirar lo antiguo despuésEn un rolling deploy, un rename directo rompe procesos viejos. Estrategia:
Añadir total_amount nullable.
Desplegar código que lee nuevo con fallback y escribe ambos.
Backfill por lotes.
Verificar equivalencia.
Cambiar todos los lectores.
Dejar de escribir total.
Eliminar columna antigua en otro release.
Dual write necesita duración corta, observabilidad y una fuente de verdad definida.
Añadir columna nullable o con default compatible.
Nuevas escrituras la completan.
Backfill idempotente.
Añadir CHECK (column IS NOT NULL) NOT VALID cuando la estrategia/versión lo justifique.
VALIDATE CONSTRAINT.
Convertir a NOT NULL mediante ruta compatible.
Validar y cambiar catálogo tienen locks distintos; revisa versión y tabla.
SQL
Copiar ALTER TABLE order_items
ADD CONSTRAINT order_items_order_fk
FOREIGN KEY ( order_id) REFERENCES orders( id)
NOT VALID;
ALTER TABLE order_items
VALIDATE CONSTRAINT order_items_order_fk; Filas nuevas se comprueban; la validación histórica se ejecuta después con menor bloqueo que un alta inmediata ordinaria.
SQL
Copiar CREATE INDEX CONCURRENTLY orders_customer_idx
ON orders( customer_id) ;
No puede ejecutarse dentro de transaction block.
Hace más trabajo y tarda más.
Puede dejar índice inválido si falla.
Sigue adquiriendo locks breves en fases.
La herramienta debe permitir migraciones no transaccionales. Después verifica indisvalid y limpia/reintenta conscientemente.
DDL aparentemente instantáneo puede esperar detrás de una transacción larga. Mientras espera, puede formar una cola.
SQL
Copiar SET lock_timeout = '2s' ;
SET statement_timeout = '30s' ; y reintenta en ventana controlada. No dejes una migración esperando indefinidamente.
El comportamiento de ADD COLUMN ... DEFAULT mejoró en versiones modernas para defaults constantes, pero expresiones volátiles, cambios de tipo y otras operaciones pueden reescribir la tabla. Comprueba la major version y prueba con volumen real.
Lotes por keyset/rango.
Transacciones cortas.
Idempotencia.
Rate limit.
Métricas de progreso.
Pausa/reanudación.
Control de replication lag y WAL.
SQL
Copiar UPDATE orders
SET total_amount = total
WHERE id > $1 AND id <= $2
AND total_amount IS NULL ; No uses OFFSET para progreso mutable. Registra last key y cuenta pendientes.
Algunos casts son metadata-only; otros reescriben toda la tabla:
SQL
Copiar ALTER TABLE events
ALTER COLUMN payload TYPE jsonb USING payload::jsonb; Para tablas grandes puede convenir columna paralela + backfill + cutover.
No toda migración es reversible:
DROP COLUMN pierde datos.
Un backfill transforma semántica.
La app nueva puede haber escrito formato incompatible.
Rollback puede significar:
Feature flag.
Volver aplicación mientras schema sigue expandido.
Forward fix.
Restore/PITR para desastre.
No confíes en un down() generado automáticamente.
Migration role posee o asume owner. Runtime no debe ejecutar DDL. Las migraciones deben aplicar grants/default privileges y calificar schemas.
Nunca edites una migración ya aplicada. Crea una nueva corrección. El mismo identificador con SQL distinto rompe reproducibilidad entre entornos.
Prueba desde snapshot representativo:
Duración.
Locks y cola.
WAL y replica lag.
Compatibilidad app vieja/nueva.
Reintento tras fallo parcial.
Índice inválido.
Backfill pausado.
Rollback/forward fix.
Restore.
Checks post-deploy.
Texto
Copiar schema expand
→ app compatible
→ backfill
→ verificación
→ app usa nuevo contrato
→ contract posteriorNo mezcles eliminación destructiva con el primer deploy consumidor.
Réplica de lectura recibe query nueva antes de aplicar WAL.
DDL invalida prepared statements.
Trigger duplica trabajo durante dual write.
Backfill compite con autovacuum.
ORM genera SQL bloqueante.
Failover ocurre a mitad del proceso.
DDL directo y destructivo.
Backfill gigante en una transacción.
CREATE INDEX normal sobre tabla activa grande.
Sin lock timeout.
Editar migración aplicada.
Rollback ficticio.
No verificar réplica ni pool.
Schema esperado.
Constraints válidas.
Índices válidos/usados.
Backfill completo.
No quedan dual writes involuntarios.
Métricas normales.
App vieja ya no desplegable antes de contract.
Runbook actualizado.
¿Por qué expand-contract permite rolling deploy?
¿Qué riesgo tiene un DDL esperando lock?
¿Por qué un backfill debe ser idempotente?
¿Por qué down no garantiza rollback?
¿Qué verificas tras CREATE INDEX CONCURRENTLY fallido?
Ver respuestas
Mantiene temporalmente contratos compatibles para ambas versiones.
Puede formar una cola y bloquear tráfico posterior.
Para reanudar o repetir sin duplicar/corromper efectos.
Los datos y semántica pueden haberse perdido o cambiado.
Si quedó un índice inválido y cómo eliminarlo/reintentarlo.
Seeds, fixtures y datos de prueba separa referencia, pruebas y demostración sin contaminar producción.