Módulo 5: Zero-Downtime Migrations
Expand-contract pattern
Descripción de la cápsula
Ya sabes qué operaciones de schema toman lock exclusivo y por qué eso voltea producción (cápsula 02). Ahora aprenderás la mecánica fundamental que resuelve el problema sin downtime: el patrón expand-contract. Es la técnica que permite descomponer una operación peligrosa (agregar columna NOT NULL a tabla con 50M filas, renombrar columna usada por la app, cambiar tipo de columna) en una secuencia de pasos seguros, cada uno tan corto que no forma cola en producción.
La idea central tiene un nombre muy descriptivo: expand primero (agregas la nueva forma del schema, sin tocar la vieja), migrate los datos a esa nueva forma, swap el código de la app para usar lo nuevo, y contract al final (eliminas la forma vieja). Durante todo el proceso, el schema vive en un estado intermedio donde app vieja Y app nueva pueden coexistir, lo cual es lo que habilita rolling deploys sin downtime.
En esta cápsula vas a escribir tres migrations Alembic concretas (no pseudocódigo) sobre un caso real: agregar columna tasks.priority INTEGER NOT NULL DEFAULT 0 a una tabla con 1M filas. Vas a ver el archivo versions/xxx_expand.py, el archivo versions/yyy_backfill.py y el archivo versions/zzz_contract.py con su downgrade respectivo. Y vas a aprender el patrón de "rename de columna en 4 deploys" (más complejo que add) como variante del mismo principio.
Al terminar tendrás internalizado el patrón que es la pieza central de todo este módulo. Las cápsulas siguientes (CONCURRENTLY, lock_timeout, idempotencia, runbook) son técnicas complementarias que se suman a esta mecánica base.
Modelo mental: la app y el schema viven en versiones distintas
En un mundo sin rolling deploys, la app y el schema cambian al mismo tiempo: detienes la app, corres la migration, levantas la app nueva. Schema y código siempre están sincronizados.
En el mundo de zero-downtime, eso es imposible. Los rolling deploys despliegan la app gradualmente: primero el 10% de pods, luego el 50%, luego el 100%. Durante ese rolling, conviven simultáneamente pods con la app vieja y pods con la app nueva, golpeando la misma DB. Si el schema cambia "de golpe", los pods viejos rompen porque ya no entienden el nuevo schema.
La solución es desacoplar el cambio de schema del cambio de código. Piensa en esto como construir un puente nuevo paralelo al viejo:
- Expand: construyes el puente nuevo al lado del viejo. Ambos puentes están operativos. Los autos pueden pasar por cualquiera. (Schema nuevo agregado, schema viejo intacto.)
- Migrate: rediriges el tráfico gradualmente al puente nuevo (deploy de la app nueva que escribe en el schema nuevo). Los autos viejos siguen pudiendo usar el viejo. (Datos copiados al schema nuevo. App nueva usa el schema nuevo. App vieja usa el schema viejo.)
- Swap: todo el tráfico está en el puente nuevo. (Todos los pods son de la app nueva.)
- Contract: demueles el puente viejo, ahora sin uso. (Eliminas el schema viejo.)
Cada paso es lo suficientemente granular para no romper a nadie. Y cada paso es rollback-able sin pérdida de datos mientras no llegues al contract. Esa última parte es crucial: la window de rollback se cierra solo en el último deploy.
El caso de estudio: agregar priority a tasks (1M filas)
Vamos al caso concreto que vas a ejecutar en este módulo y que reaparece en el proyecto del módulo 8 (TaskFlow).
Estado inicial:
# app/db/models/task.py
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[int] = mapped_column(index=True)
title: Mapped[str]
created_at: Mapped["datetime"]
Estado final que queremos:
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[int] = mapped_column(index=True)
title: Mapped[str]
priority: Mapped[int] = mapped_column(default=0) # NOT NULL DEFAULT 0
created_at: Mapped["datetime"]
La operación SQL ingenua:
ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
Como vimos en la cápsula 02, en PostgreSQL 16 con DEFAULT literal esto NO reescribe la tabla. Lock corto. Técnicamente "seguro" para tabla pequeña. Pero hay razones operacionales para hacerlo en 3 deploys aunque sea "seguro":
- Window de rollback. Si después del deploy detectas un bug en la nueva feature de priority, queremos poder hacer rollback del código sin tener que rollback del schema. El expand-contract permite esto.
- Tabla grande. La tabla puede ser pequeña hoy y enorme en 6 meses. Adoptar el patrón ahora hace que la operación escale sin sorpresas.
- Disciplina del equipo. Los reflejos se construyen con repetición. Si haces expand-contract siempre que la operación incluye NOT NULL, nunca te equivocarás cuando realmente importa.
- El backfill no es trivial en tablas grandes. Para 50M filas, el backfill toma minutos. Ese tiempo necesita ser su propia operación, no parte de un deploy.
Las 4 fases del expand-contract
Ahora vamos a escribir cada fase como migration Alembic real. Asume que tu directorio Alembic tiene la estructura estándar:
alembic/
├── env.py
├── script.py.mako
└── versions/
├── 001_initial_tasks_table.py
├── 002_add_priority_expand.py ← La vamos a escribir
├── 003_backfill_priority.py ← La vamos a escribir
└── 004_priority_set_not_null_contract.py ← La vamos a escribir
Fase 1 — Expand: agregar columna nullable
Agregamos la columna como NULL primero. Lock corto, no afecta tráfico, app vieja sigue funcionando (no conoce la columna y no la necesita).
# alembic/versions/002_add_priority_expand.py
"""add priority column (expand)
Revision ID: 002_add_priority_expand
Revises: 001_initial_tasks_table
Create Date: 2026-05-02 14:30:00
"""
from alembic import op
import sqlalchemy as sa
revision = "002_add_priority_expand"
down_revision = "001_initial_tasks_table"
branch_labels = None
depends_on = None
def upgrade():
# Setear lock_timeout para fallar rápido si algo cuelga
op.execute("SET lock_timeout = '5s'")
# Agregar columna NULL — lock ACCESS EXCLUSIVE breve (~10ms)
# NULL permitido durante la window de rollback (deploy 1-2)
op.add_column(
"tasks",
sa.Column("priority", sa.Integer(), nullable=True),
)
def downgrade():
# Si necesitamos rollback de este deploy, dropeamos la columna.
# Window de rollback: hasta antes de empezar el backfill (deploy siguiente).
op.execute("SET lock_timeout = '5s'")
op.drop_column("tasks", "priority")
Características de esta fase:
- Lock ACCESS EXCLUSIVE breve porque
ADD COLUMN NULLes solo metadata. SET lock_timeout = '5s'al inicio: si por alguna razón hay otra operación tomando lock entasks, fallamos rápido en lugar de quedarnos colgados (cápsula 05 profundiza este patrón).- App vieja sigue funcionando: no conoce la columna
priority. Sus INSERTs no la mencionan. Sus SELECTs la ignoran (SQLAlchemy solo materializa columnas declaradas en el modelo). - App nueva (que se desplegaría después de esta migration) puede empezar a usar la columna. Tolera que las filas viejas tengan NULL.
- Downgrade limpio: dropear la columna recupera el estado original. Window de rollback fuerte.
Fase 2 — Migrate (backfill): poblar la columna
Llenamos la columna priority con el valor por defecto (0) para todas las filas viejas. Hacemos esto en batches para no tomar un lock largo.
Por qué no un solo UPDATE:
-- ❌ MAL para tabla grande: lock por minutos
UPDATE tasks SET priority = 0 WHERE priority IS NULL;
En tabla de 1M filas, este UPDATE toma ROW EXCLUSIVE sobre toda la tabla durante el tiempo que tarde. En tabla de 50M filas son minutos. Durante ese tiempo, otros UPDATEs/DELETEs sobre la misma tabla forman cola.
Patrón correcto: batches con sleep.
# alembic/versions/003_backfill_priority.py
"""backfill priority column (migrate phase)
Revision ID: 003_backfill_priority
Revises: 002_add_priority_expand
Create Date: 2026-05-02 14:35:00
"""
import time
from alembic import op
import sqlalchemy as sa
revision = "003_backfill_priority"
down_revision = "002_add_priority_expand"
branch_labels = None
depends_on = None
BATCH_SIZE = 10_000
SLEEP_BETWEEN_BATCHES_SEC = 0.1
def upgrade():
# Backfills pueden ser largos. Desactivar statement_timeout local
# para que la conexión no muera si el global es 30s.
op.execute("SET LOCAL statement_timeout = '0'")
connection = op.get_bind()
# Obtener el rango de IDs a procesar
max_id_result = connection.execute(
sa.text("SELECT COALESCE(MAX(id), 0) FROM tasks WHERE priority IS NULL")
)
max_id = max_id_result.scalar() or 0
if max_id == 0:
print("Nada por backfillear.")
return
print(f"Backfilling priority en batches de {BATCH_SIZE}, max_id={max_id}")
batch_min = 0
rows_total = 0
batches = 0
while batch_min <= max_id:
batch_max = batch_min + BATCH_SIZE - 1
# UPDATE en rango específico de IDs (lock acotado a esas filas)
result = connection.execute(
sa.text(
"""
UPDATE tasks
SET priority = 0
WHERE id BETWEEN :batch_min AND :batch_max
AND priority IS NULL
"""
),
{"batch_min": batch_min, "batch_max": batch_max},
)
rows_in_batch = result.rowcount or 0
rows_total += rows_in_batch
batches += 1
if batches % 10 == 0:
pct = min(100, (batch_min / max_id) * 100)
print(
f"Batch {batches}: progreso {pct:.1f}% "
f"(filas actualizadas total: {rows_total})"
)
batch_min = batch_max + 1
# Sleep entre batches para dejar respirar a otros queries
time.sleep(SLEEP_BETWEEN_BATCHES_SEC)
print(
f"Backfill completo. Batches: {batches}, "
f"filas actualizadas: {rows_total}"
)
# Verificación final: 0 filas con priority NULL
null_count = connection.execute(
sa.text("SELECT COUNT(*) FROM tasks WHERE priority IS NULL")
).scalar()
assert null_count == 0, (
f"Backfill incompleto: aún hay {null_count} filas con priority IS NULL"
)
def downgrade():
# Reset de la columna a NULL.
# Solo tiene sentido si vamos a hacer rollback hasta antes del expand.
op.execute("UPDATE tasks SET priority = NULL")
Características de esta fase:
- Batches de 10k filas: lock acotado a esas filas, no a la tabla. Otros writes a otras filas no se bloquean.
UPDATE ... WHERE id BETWEEN :batch_min AND :batch_max: usa el índice del PK. Acceso eficiente, no full scan.- Sleep de 100ms entre batches: da espacio a otras queries para no saturar la DB. En tablas muy hot, considera 200-500ms.
SET LOCAL statement_timeout = '0': el backfill puede tardar minutos. Si el global es 30s, sin esto la operación muere.- Logging de progreso: crítico para operaciones largas. Si el backfill se rompe a mitad, el log te dice dónde retomar.
- Verificación final:
assert null_count == 0falla la migration si queda algún NULL. Esto es defensivo: si por alguna razón el backfill no cubrió todas las filas (filas insertadas durante el proceso por la app vieja), te enteras antes del Deploy 3.
Consideración: ¿qué pasa con filas insertadas DURANTE el backfill?
Si la app vieja sigue insertando filas sin priority mientras el backfill corre, esas filas llegarán como NULL. El loop del backfill las captura cuando entra a su rango de ID. Al final, la verificación null_count == 0 confirma que todas están cubiertas. Si el assert falla, significa que la app vieja sigue insertando y el backfill quedó atrás — necesitas correr el backfill de nuevo o desplegar la app nueva primero.
Para evitar la race condition completamente: despliega la app nueva (que escribe priority siempre) ANTES del backfill. Así, durante el backfill, las filas nuevas ya tienen priority correctamente y el backfill solo cubre las históricas. Esto es el "Deploy 2 entre fase 1 y fase 2" que se ve en algunos esquemas.
Fase 3 — Swap: deploy de la app nueva (no es DDL)
Esto NO es una migration de Alembic. Es el deploy del código de la app que ahora siempre escribe priority en INSERTs.
Antes del deploy (app vieja):
# app/api/tasks.py
async def create_task(title: str, db: AsyncSession = Depends(...)):
task = Task(title=title)
# priority no se setea — va como NULL
db.add(task)
Después del deploy (app nueva):
# app/api/tasks.py
async def create_task(
title: str,
priority: int = 0,
db: AsyncSession = Depends(...),
):
task = Task(title=title, priority=priority) # priority siempre seteado
db.add(task)
Verificación antes de avanzar al Deploy 3:
-- Toda fila debe tener priority no NULL
SELECT COUNT(*) FROM tasks WHERE priority IS NULL;
-- Esperado: 0
Si cuenta no es 0, hay un bug en el deploy de la app (algún endpoint sigue creando con NULL) o el rollout no está completo (queda un pod viejo). NO avances al Deploy 3 hasta resolver.
Fase 4 — Contract: SET NOT NULL final
Ya tenemos garantizado que ninguna fila tiene NULL. Podemos endurecer el constraint:
# alembic/versions/004_priority_set_not_null_contract.py
"""make priority NOT NULL (contract phase)
Revision ID: 004_priority_set_not_null_contract
Revises: 003_backfill_priority
Create Date: 2026-05-02 14:50:00
"""
from alembic import op
revision = "004_priority_set_not_null_contract"
down_revision = "003_backfill_priority"
branch_labels = None
depends_on = None
def upgrade():
# Setear lock_timeout para fallar rápido
op.execute("SET lock_timeout = '5s'")
# Patrón "NOT VALID truco" para evitar el full scan bloqueante
# Step 1: agregar CHECK constraint NOT VALID — lock corto
op.execute(
"""
ALTER TABLE tasks
ADD CONSTRAINT tasks_priority_check
CHECK (priority IS NOT NULL) NOT VALID
"""
)
# Step 2: validar el constraint — lock SHARE UPDATE EXCLUSIVE
# (compatible con writes y reads, solo bloquea otros DDL)
op.execute("ALTER TABLE tasks VALIDATE CONSTRAINT tasks_priority_check")
# Step 3: ahora SET NOT NULL es metadata-only (PG sabe que se cumple)
op.alter_column("tasks", "priority", nullable=False)
# Step 4: limpiar el CHECK redundante
op.execute("ALTER TABLE tasks DROP CONSTRAINT tasks_priority_check")
# Step 5: opcionalmente, agregar el DEFAULT a nivel DB
op.execute("ALTER TABLE tasks ALTER COLUMN priority SET DEFAULT 0")
def downgrade():
# Rollback de NOT NULL a NULL (operación rápida)
op.execute("SET lock_timeout = '5s'")
op.execute("ALTER TABLE tasks ALTER COLUMN priority DROP DEFAULT")
op.alter_column("tasks", "priority", nullable=True)
Características de esta fase:
- El truco de NOT VALID evita el full scan bloqueante que tomaría
ALTER COLUMN ... SET NOT NULLdirecto. La idea: agregar el CHECK como NOT VALID es metadata-only (instant). Luego VALIDATE lee la tabla con un lock más liviano (compatible con writes). DespuésSET NOT NULLes instant porque PostgreSQL ya sabe que el constraint se cumple. - El downgrade vuelve a nullable, lo cual es metadata-only. Window de rollback aquí es limitada: si después de este deploy la app empieza a depender de NOT NULL (ej. lógica que asume
priority is not None), un rollback te deja en estado donde podrían entrar NULLs en filas nuevas. Cuidado.
El caso más complejo: rename de columna en 4 deploys
Add column nullable es el caso simple. Renombrar una columna es donde el patrón se vuelve más interesante porque la columna vieja Y la nueva tienen que coexistir mientras la app rolla.
Caso: renombrar tasks.title a tasks.name.
Por qué no ALTER TABLE tasks RENAME COLUMN title TO name:
- Lock ACCESS EXCLUSIVE breve (es solo metadata), pero...
- Inmediatamente después del rename, la app vieja rompe: sus queries dicen
SELECT title FROM tasksy la columna ya no existe. - En rolling deploy, esto significa que los pods viejos rompen mientras los nuevos funcionan. Errores 500 hasta que el rollout complete.
Patrón correcto: 4 deploys.
Deploy 1 — Add new column (expand)
# alembic/versions/00X_rename_title_to_name_step1_add_new.py
def upgrade():
op.execute("SET lock_timeout = '5s'")
# Agregar columna nueva nullable
op.add_column("tasks", sa.Column("name", sa.String(), nullable=True))
def downgrade():
op.execute("SET lock_timeout = '5s'")
op.drop_column("tasks", "name")
Deploy 2 — Write both columns (app cambia)
Cambio de código: la app nueva escribe a AMBAS columnas (title y name) en INSERT/UPDATE. Lee solo de title (para no romper queries).
# app/db/models/task.py
class Task(Base):
title: Mapped[str]
name: Mapped[str | None] # Nueva, nullable temporalmente
@property
def display_name(self) -> str:
return self.name or self.title # Fallback a title
# Cuando se crea o actualiza un task:
task.title = "foo"
task.name = "foo" # Escritura duplicada temporal
Después de este deploy, todas las nuevas filas tienen tanto title como name. Las viejas siguen con name = NULL.
Deploy 2.5 — Backfill name desde title
# alembic/versions/00X_rename_title_to_name_step2_backfill.py
def upgrade():
op.execute("SET LOCAL statement_timeout = '0'")
connection = op.get_bind()
# Backfill en batches (mismo patrón que el ejemplo anterior)
BATCH_SIZE = 10_000
SLEEP_SEC = 0.1
max_id = connection.execute(
sa.text("SELECT COALESCE(MAX(id), 0) FROM tasks WHERE name IS NULL")
).scalar()
batch_min = 0
while batch_min <= max_id:
batch_max = batch_min + BATCH_SIZE - 1
connection.execute(
sa.text(
"""
UPDATE tasks SET name = title
WHERE id BETWEEN :batch_min AND :batch_max
AND name IS NULL
"""
),
{"batch_min": batch_min, "batch_max": batch_max},
)
batch_min = batch_max + 1
time.sleep(SLEEP_SEC)
null_count = connection.execute(
sa.text("SELECT COUNT(*) FROM tasks WHERE name IS NULL")
).scalar()
assert null_count == 0
Deploy 3 — Swap reads to new column
Cambio de código: la app nueva ahora lee de name (en lugar de title), pero sigue escribiendo a ambas. Esto permite que cualquier rollback del Deploy 3 a Deploy 2 funcione (Deploy 2 lee de title, que sigue actualizado).
class Task(Base):
title: Mapped[str] # Sigue existiendo
name: Mapped[str] # Ya NOT NULL después del backfill
@property
def display_name(self) -> str:
return self.name # Ahora primario
Y todas las queries que usaban Task.title ahora usan Task.name.
Deploy 4 — Stop writing to old column (contract step 1)
Cambio de código: la app deja de escribir a title. Solo escribe a name.
# Ya no:
task.title = "foo"
# Solo:
task.name = "foo"
Esto cierra la window de rollback del rename: si haces rollback después de este deploy, la app vieja escribiría a title (que en la DB sigue existiendo) y leería de title... pero name no se actualizaría con el código nuevo en el rollback. Necesitas planear este punto bien.
Deploy 5 — Drop old column (contract final)
# alembic/versions/00X_rename_title_to_name_step5_drop_old.py
def upgrade():
op.execute("SET lock_timeout = '5s'")
op.drop_column("tasks", "title")
def downgrade():
# Recreating dropped column es operación destructiva sin los datos.
# Solo tiene sentido si el deploy anterior restaura el código que escribe a title.
op.execute("SET lock_timeout = '5s'")
op.add_column("tasks", sa.Column("title", sa.String(), nullable=True))
# Backfill desde name
op.execute("UPDATE tasks SET title = name")
op.alter_column("tasks", "title", nullable=False)
Resumen del rename:
- 5 deploys (3 de DB, 2 de código), durante 1-2 semanas idealmente.
- En cualquier punto puedes hacer rollback al deploy anterior sin pérdida de datos.
- La columna vieja existe durante toda la window, asegurando compatibilidad con app vieja.
Es laborioso. Pero es la única forma de hacerlo sin downtime cuando la columna está siendo activamente usada por la app.
¿Por qué importa esto en el trabajo real?
1. Es el patrón que separa al senior del junior en migrations productivas. Cualquier dev puede ejecutar ALTER TABLE que funcione en local. Pero solo el que entiende expand-contract puede ejecutar evoluciones de schema en producción multi-tenant 24/7 sin que nadie note.
2. Es exactamente lo que el proyecto del módulo 8 te pide. El expand-contract para tasks.priority que estamos viendo aquí es el mismo flujo que el proyecto final ejecuta en vivo. Si dominas esta cápsula, el proyecto final es aplicación.
3. Te ahorra deuda técnica permanente. Equipos que no dominan expand-contract acumulan "schema rot": columnas que se quedaron porque renombrarlas era riesgoso, tipos incorrectos que nunca se cambiaron, índices que no se reorganizaron. Con expand-contract, la fricción de evolucionar schema baja, y el schema se mantiene saludable.
4. Es parte del lenguaje común de DBA y backend senior. En postmortems, RFCs, y reuniones de arquitectura, "vamos a hacer un expand-contract" es shorthand entendido. Saber el patrón te da fluencia para participar en esas conversaciones.
Trampas y errores comunes
Error 1 (conceptual): asumir que expand-contract es "para tablas grandes solamente"
Síntoma: un dev decide hacer expand-contract solo cuando la tabla tiene >10M filas. En tablas chicas hace ADD COLUMN ... NOT NULL DEFAULT directo. Funciona... hasta que la tabla crece y un día la operación tarda más de la cuenta.
Por qué pasa: el patrón parece "innecesario" para tablas chicas. Pero el costo de adoptarlo es bajo (3 deploys vs 1) y el beneficio es robustez ante crecimiento.
Cómo distinguirlo: ¿la operación incluye NOT NULL para filas existentes? ¿La operación cambia tipo? ¿La operación renombra/dropea? Si sí, expand-contract es el default, sin importar el tamaño.
Cómo corregir: adopta el patrón como default. La excepción es operaciones obviamente seguras (ADD COLUMN nullable, CREATE INDEX CONCURRENTLY) que no requieren coexistencia de versiones.
Error 2 (operacional): hacer todo en una sola migration "para simplificar"
Síntoma: un dev ve que tiene que hacer 3 deploys para una operación y decide "agruparlos" en una sola migration con todos los pasos. Hace expand + backfill + contract en el mismo upgrade(). La migration tarda 10 minutos y bloquea producción.
Por qué pasa: los 3 deploys parecen redundantes. Agruparlos parece eficiente. Pero el punto del expand-contract es que cada paso ocurre con la app desplegada en su versión correspondiente. Si los corres todos juntos, no hay coexistencia, y el patrón pierde su valor.
Cómo distinguirlo: si tu migration ejecuta más de una "fase" (expand Y backfill, o backfill Y contract), está mal. Cada fase es su propia migration, en su propio deploy.
Cómo corregir: separa físicamente las migrations. Si Alembic genera autogenerate con todo junto, edita los archivos manualmente para separar.
Error 3 (conceptual): no entender que el backfill puede correr DURANTE el tráfico
Síntoma: un dev programa el backfill en una "ventana de mantenimiento" porque tiene miedo de que afecte producción. Anuncia downtime por backfill. Pero el patrón de batches está diseñado precisamente para no afectar producción.
Por qué pasa: la palabra "backfill" suena pesada. La intuición dice "esto va a tomar lock largo, mejor en ventana".
Cómo distinguirlo: si tu backfill está bien diseñado (batches de 10k con sleep, lock por batch acotado), no toma locks largos. Puede correr en hora pico sin impacto medible. La cápsula 06 profundiza el patrón.
Cómo corregir: confía en el patrón de batches. Mide en staging primero (con wrk corriendo) para validar que efectivamente no impacta latencia. Después ejecuta en prod sin downtime declarado.
Error 4 (operacional): no verificar que el backfill terminó antes del contract
Síntoma: un dev ejecuta los 3 deploys en cadena rápida. El backfill no termina (por alguna razón: la app vieja sigue insertando NULLs, hubo un error a mitad). El contract SET NOT NULL falla porque hay filas con NULL.
Por qué pasa: las migrations se ejecutan en piloto automático. No hay "stop" entre ellas para verificar.
Cómo distinguirlo: ¿tu pipeline de deploy ejecuta migrations sin verificación intermedia? ¿Confías en que cada migration termina exitosamente sin checks?
Cómo corregir: entre Deploy 2 (backfill) y Deploy 3 (contract), agrega un step manual de verificación:
SELECT COUNT(*) FROM tasks WHERE priority IS NULL;
-- Debe ser 0 antes de avanzar
Si automatizas el deploy, agrega el check como precondición en el pipeline.
Error 5 (conceptual): pensar que el rollback siempre es simple
Síntoma: un dev asume "si algo falla, hago alembic downgrade -1 y vuelvo al estado anterior". En la fase de contract, el downgrade no recupera completamente el estado original sin perder datos (la columna NOT NULL pasaría a NULL, pero las filas insertadas durante el contract ya no se rollbackean trivialmente).
Por qué pasa: el modelo mental de "downgrade simétrico al upgrade" no aplica universalmente. Algunas operaciones son no-reversibles sin pérdida.
Cómo distinguirlo: para cada migration, pregunta: "si quiero hacer rollback después de este deploy, ¿hay pérdida de datos? ¿qué pasa con filas creadas después del upgrade?"
Cómo corregir: documenta la window de rollback de cada fase explícitamente. La cápsula 07 cubre esto en profundidad.
Ejercicios
Ejercicio 1: identificar las fases en un caso real
Dado este requerimiento de producto: "agregar columna users.email_verified BOOLEAN NOT NULL DEFAULT false a la tabla users que tiene 8M filas, en producción 24/7 multi-tenant".
Especifica las fases del expand-contract para esta operación. Para cada fase, indica:
- Si es DDL (Alembic) o cambio de código de app.
- El SQL exacto si aplica.
- El lock que toma.
- Si la window de rollback sigue abierta después.
Ver solución
Análisis previo: PostgreSQL 16 con DEFAULT literal false (booleano constante) NO reescribe la tabla. Técnicamente esto podría hacerse en 1 deploy seguro. Pero seguimos el patrón para tener window de rollback y disciplina.
Fase 1 — Expand (Deploy 1, DDL):
SET lock_timeout = '5s';
ALTER TABLE users ADD COLUMN email_verified BOOLEAN NULL;
- Lock: ACCESS EXCLUSIVE breve (~10ms).
- Window de rollback: total. Drop column es trivial.
Fase 1.5 — Backfill (Deploy 2 o ejecutado standalone, DDL):
# Backfill en batches
BATCH_SIZE = 10_000
SLEEP_SEC = 0.1
batch_min = 0
max_id = SELECT MAX(id) FROM users WHERE email_verified IS NULL
while batch_min <= max_id:
UPDATE users SET email_verified = false
WHERE id BETWEEN batch_min AND batch_min + BATCH_SIZE - 1
AND email_verified IS NULL
sleep(SLEEP_SEC)
- Lock: ROW EXCLUSIVE por batch (10k filas).
- Tiempo: ~5-10 minutos para 8M filas.
- Window de rollback: total. Reset a NULL es trivial.
Fase 2 — Migrate (Deploy 3, código de app):
- Cambio de código: la app nueva siempre escribe
email_verifieden INSERTs (default false explícito). - No hay DDL.
- Verificar:
SELECT COUNT(*) FROM users WHERE email_verified IS NULLdebe ser 0.
Fase 3 — Contract (Deploy 4, DDL):
SET lock_timeout = '5s';
ALTER TABLE users ADD CONSTRAINT users_email_verified_not_null
CHECK (email_verified IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_email_verified_not_null;
ALTER TABLE users ALTER COLUMN email_verified SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_email_verified_not_null;
ALTER TABLE users ALTER COLUMN email_verified SET DEFAULT false;
- Lock: SHARE UPDATE EXCLUSIVE durante VALIDATE (compatible con writes), ACCESS EXCLUSIVE breve para SET NOT NULL.
- Window de rollback: limitada. Si rollback, vuelve a NULL pero la app nueva ya asume NOT NULL.
Ejercicio 2: detectar bugs en un expand-contract mal escrito
Esta migration intenta hacer expand-contract para agregar tasks.priority. Tiene tres problemas. Identifícalos y propón corrección.
# alembic/versions/wrong_expand_contract.py
def upgrade():
op.add_column("tasks", sa.Column("priority", sa.Integer(), nullable=False, server_default="0"))
op.execute("UPDATE tasks SET priority = 0")
op.alter_column("tasks", "priority", server_default=None)
def downgrade():
op.drop_column("tasks", "priority")
Ver solución
Bug 1: todo está en una sola migration.
Expand, backfill y contract en el mismo upgrade(). Esto colapsa el patrón: no hay coexistencia entre versiones de app, no hay window de rollback granular, y el lock dura más (porque todas las operaciones se ejecutan secuencialmente bloqueando la tabla).
Corrección: separar en tres migrations distintas, cada una en su propio deploy.
Bug 2: ADD COLUMN ... NOT NULL DEFAULT '0' ejecutado primero.
Esto agrega la columna ya con NOT NULL desde el inicio. En PG 11+ con literal funciona sin reescritura, pero pasa por alto el principio del expand-contract: la app vieja no sabe de la columna y NO escribirá un valor en INSERTs nuevos durante el rolling deploy. Si la app vieja hace un INSERT durante la window entre la migration y el rollout completo, ¿qué pasa? El INSERT debería tomar el DEFAULT, así que técnicamente funciona... pero estás dependiendo del DEFAULT a nivel DB en lugar de manejarlo en app. Mezcla responsabilidades.
Más grave: si la operación fuera DEFAULT gen_random_uuid() (función volátil), reescribiría la tabla bloqueando minutos.
Corrección: agregar como NULL, hacer backfill, después SET NOT NULL.
Bug 3: UPDATE tasks SET priority = 0 sin batches.
Para tabla grande, este UPDATE toma lock prolongado. Ya cubierto en la cápsula y el ejercicio anterior.
Corrección: usar batches con sleep, como en el ejemplo del backfill.
Versión corregida (separada en 3 migrations):
# Migration 1: expand
def upgrade():
op.execute("SET lock_timeout = '5s'")
op.add_column("tasks", sa.Column("priority", sa.Integer(), nullable=True))
def downgrade():
op.drop_column("tasks", "priority")
# Migration 2: backfill (con batches)
def upgrade():
op.execute("SET LOCAL statement_timeout = '0'")
connection = op.get_bind()
BATCH_SIZE = 10_000
max_id = connection.execute(
sa.text("SELECT COALESCE(MAX(id), 0) FROM tasks WHERE priority IS NULL")
).scalar()
batch_min = 0
while batch_min <= max_id:
connection.execute(
sa.text(
"UPDATE tasks SET priority = 0 "
"WHERE id BETWEEN :a AND :b AND priority IS NULL"
),
{"a": batch_min, "b": batch_min + BATCH_SIZE - 1},
)
batch_min += BATCH_SIZE
time.sleep(0.1)
def downgrade():
op.execute("UPDATE tasks SET priority = NULL")
# Migration 3: contract
def upgrade():
op.execute("SET lock_timeout = '5s'")
op.execute(
"ALTER TABLE tasks ADD CONSTRAINT tasks_priority_check "
"CHECK (priority IS NOT NULL) NOT VALID"
)
op.execute("ALTER TABLE tasks VALIDATE CONSTRAINT tasks_priority_check")
op.alter_column("tasks", "priority", nullable=False)
op.execute("ALTER TABLE tasks DROP CONSTRAINT tasks_priority_check")
op.execute("ALTER TABLE tasks ALTER COLUMN priority SET DEFAULT 0")
def downgrade():
op.alter_column("tasks", "priority", nullable=True)
op.execute("ALTER TABLE tasks ALTER COLUMN priority DROP DEFAULT")
Ejercicio 3: implementar un expand-contract para "renombrar columna"
Sobre tu app FastAPI con la tabla tasks, implementa el rename de tasks.title a tasks.name. Escribe los archivos Alembic completos para los pasos DDL (Deploy 1, Deploy 2.5 backfill, Deploy 5 drop). Para los pasos de código (Deploy 2, 3, 4), describe en comentarios qué cambia.
Ver solución
# alembic/versions/00A_rename_title_to_name_step1_add_new.py
"""Rename tasks.title -> tasks.name (step 1: add new column)"""
from alembic import op
import sqlalchemy as sa
revision = "00A_rename_step1"
down_revision = "previous_revision"
def upgrade():
op.execute("SET lock_timeout = '5s'")
op.add_column("tasks", sa.Column("name", sa.String(), nullable=True))
def downgrade():
op.execute("SET lock_timeout = '5s'")
op.drop_column("tasks", "name")
# ─────────────────────────────────────────────
# Deploy 2 (código de app, no DDL):
# Cambio de código:
# - El modelo Task agrega `name: Mapped[str | None]`.
# - INSERTs/UPDATEs escriben simultáneamente a title y name.
# - SELECTs siguen leyendo de title.
# Resultado: filas nuevas tienen ambos campos. Las viejas tienen name=NULL.
# ─────────────────────────────────────────────
# alembic/versions/00B_rename_title_to_name_step2_backfill.py
"""Rename tasks.title -> tasks.name (step 2: backfill name from title)"""
import time
from alembic import op
import sqlalchemy as sa
revision = "00B_rename_step2"
down_revision = "00A_rename_step1"
def upgrade():
op.execute("SET LOCAL statement_timeout = '0'")
connection = op.get_bind()
BATCH_SIZE = 10_000
SLEEP_SEC = 0.1
max_id = connection.execute(
sa.text("SELECT COALESCE(MAX(id), 0) FROM tasks WHERE name IS NULL")
).scalar() or 0
batch_min = 0
while batch_min <= max_id:
connection.execute(
sa.text(
"UPDATE tasks SET name = title "
"WHERE id BETWEEN :a AND :b AND name IS NULL"
),
{"a": batch_min, "b": batch_min + BATCH_SIZE - 1},
)
batch_min += BATCH_SIZE
time.sleep(SLEEP_SEC)
null_count = connection.execute(
sa.text("SELECT COUNT(*) FROM tasks WHERE name IS NULL")
).scalar()
assert null_count == 0, f"Backfill incompleto: {null_count} filas con name IS NULL"
def downgrade():
op.execute("UPDATE tasks SET name = NULL")
# ─────────────────────────────────────────────
# Deploy 3 (código de app):
# Cambio de código:
# - SELECTs ahora leen de `name` (en lugar de title).
# - INSERTs/UPDATEs siguen escribiendo a ambos.
# Window de rollback: si rollback al Deploy 2, lecturas vuelven a title (que sigue actualizado).
# ─────────────────────────────────────────────
# ─────────────────────────────────────────────
# Deploy 4 (código de app):
# Cambio de código:
# - INSERTs/UPDATEs solo escriben a `name`. Dejan de escribir a title.
# - El modelo Task ya no expone `title`.
# Window de rollback: cierre. Después de este deploy, title queda obsoleto.
# ─────────────────────────────────────────────
# alembic/versions/00C_rename_title_to_name_step5_drop_old.py
"""Rename tasks.title -> tasks.name (step 5: drop old column)"""
from alembic import op
import sqlalchemy as sa
revision = "00C_rename_step5"
down_revision = "00B_rename_step2"
def upgrade():
op.execute("SET lock_timeout = '5s'")
op.drop_column("tasks", "title")
def downgrade():
# Recrear title — los datos se recuperan desde name
op.execute("SET lock_timeout = '5s'")
op.add_column("tasks", sa.Column("title", sa.String(), nullable=True))
op.execute("UPDATE tasks SET title = name")
op.alter_column("tasks", "title", nullable=False)
Notas operacionales:
- Entre Deploy 2 y Deploy 3 (backfill), haz manual la verificación
SELECT COUNT(*) FROM tasks WHERE name IS NULLantes de avanzar. - El Deploy 4 (deja de escribir a title) cierra la window de rollback. Antes de ese deploy, todo es reversible. Después, no.
- Considera mantener title como nullable durante varias semanas/sprints después del Deploy 4 antes de dropear (Deploy 5), para tener máxima seguridad.
Ejercicio 4: ¿en qué fase puedes hacer rollback sin pérdida?
Para el caso del expand-contract de tasks.priority (3 deploys), describe la window de rollback en cada fase. Específicamente:
- Después del Deploy 1 (expand: ADD COLUMN priority INTEGER NULL), ¿qué pasa si hago rollback?
- Después del Deploy 2 (app nueva escribe priority siempre), ¿qué pasa si hago rollback al Deploy 1?
- Después del Deploy 3 (contract: SET NOT NULL), ¿qué pasa si hago rollback al Deploy 2?
Ver solución
Fase 1 — Después del expand:
- DB: columna
priorityexiste como NULL. Filas viejas tienenpriority IS NULL. - App: aún la versión vieja, no conoce la columna.
- Rollback (drop column): completamente seguro. La app no usaba la columna, dropearla no afecta nada. Sin pérdida de datos relevantes.
Fase 2 — Después del backfill:
- DB: columna
priorityexiste como NULL. Todas las filas tienenpriority = 0o el valor de backfill. - App: aún la versión vieja, no escribe priority en INSERTs nuevos.
- Rollback al estado pre-expand (drop column): seguro. El backfill se pierde pero como aún no se usa, no afecta.
Después del Deploy 2 (app nueva, sigue NULL):
- DB: columna
priorityexiste como NULL. Todas las filas tienen valor (las nuevas porque la app las escribe, las viejas por el backfill). - App: nueva, escribe priority siempre.
- Rollback de la app al Deploy 1 (app vieja): la app vieja deja de escribir priority en INSERTs nuevos. Esas filas nuevas tendrán
priority IS NULL. La columna existe, sigue siendo NULL-compatible. La app no rompe. - Si después del rollback redeployas la app nueva, las nuevas filas que entraron como NULL durante el rollback necesitan otro backfill antes del contract.
Fase 3 — Después del contract (SET NOT NULL):
- DB: columna
priorityes NOT NULL. La DB rechaza INSERTs sin priority. - App: nueva, escribe priority siempre.
- Rollback de la app al Deploy 2 (versión inmediatamente anterior): la app sigue escribiendo priority. Funciona.
- Rollback al Deploy 1 (app vieja que NO escribe priority): rompe. La DB rechaza los INSERTs con error "null value in column priority". Necesitas hacer downgrade del schema antes del rollback de la app, lo cual es compleja coordinación.
- Rollback del schema (alter column ... drop not null): operación rápida, libera el constraint. Después puedes rollback la app.
Lección clave:
- Las primeras dos fases (expand, backfill) son rollback-friendly: puedes volver atrás sin coordinación compleja.
- La tercera fase (contract) cierra la window: el rollback requiere desunificar app + schema, en orden inverso, lo cual es coordinación delicada.
- Por eso muchos equipos esperan días o semanas entre Deploy 2 y Deploy 3, monitoreando que la app nueva no tenga bugs antes de cerrar la window.
Ejercicio 5: dropping de columna sin downtime
Producto te pide eliminar la columna tasks.legacy_status (deprecada hace 6 meses, ya nadie la usa). La tabla tiene 50M filas. ¿Es seguro hacer ALTER TABLE tasks DROP COLUMN legacy_status directamente? Si no, ¿qué patrón aplicas?
Ver solución
Análisis:
DROP COLUMNtomaACCESS EXCLUSIVE LOCK.- En la mayoría de versiones modernas de PostgreSQL, DROP COLUMN no reescribe la tabla — solo marca la columna como dropeada en el catálogo. El espacio físico se libera con el próximo VACUUM FULL o con el tiempo (filas nuevas no incluyen la columna).
- Lock de DROP COLUMN: rápido (ms) si no requiere reescritura.
¿Es seguro entonces hacer DROP directo?
Casi siempre sí, a nivel de DB. PERO:
- Si tu app aún tiene código que referencia
legacy_status(aunque sea muerto), va a romper. - Si hay objetos dependientes (vistas, índices, foreign keys de otras tablas), el DROP puede fallar o cascadear.
Patrón seguro (expand-contract en sentido inverso):
Deploy 1 — Stop reading the column (código de app):
- Cambio de código: eliminar todas las referencias a
legacy_statusen la app. Asegurar que ninguna query, ningún model, ningún test usa el campo. - Después de este deploy, la columna existe en DB pero la app no la toca.
Deploy 2 — Verificación (no es deploy):
- Ejecutar log analysis: ¿hay queries que referencian la columna?
pg_stat_statementspuede ayudar. - Verificar dependencias:
SELECT * FROM information_schema.constraint_column_usage WHERE column_name = 'legacy_status'. - Si todo limpio, proceder.
Deploy 3 — Drop column (DDL):
def upgrade():
op.execute("SET lock_timeout = '5s'")
op.drop_column("tasks", "legacy_status")
def downgrade():
# Recrear es destructivo: los datos se perdieron al dropear.
# Solo recrea la columna vacía, sin recovery de datos.
op.execute("SET lock_timeout = '5s'")
op.add_column(
"tasks",
sa.Column("legacy_status", sa.String(), nullable=True),
)
Variante más conservadora — "drop suave" durante semanas:
Algunos equipos prefieren un paso intermedio:
- Deploy 2.5:
ALTER TABLE tasks ALTER COLUMN legacy_status DROP NOT NULL(si tenía NOT NULL). - Esperar 2-4 semanas con la columna sin uso.
- Confirmar (con métricas) que ningún query la toca.
- Después dropear definitivamente.
Esto da window adicional para detectar referencias olvidadas.
Conclusión: DROP COLUMN es técnicamente rápido en PostgreSQL moderno, pero el desafío es la coordinación: asegurar que la app realmente no la usa. El expand-contract aplica en sentido inverso (contract first sobre el código, después contract sobre el schema).
Ejercicio 6: caso difícil — cambiar tipo de columna
Tienes una columna tasks.points INTEGER. Producto te pide cambiarla a BIGINT porque algunos tenants están llegando a límites de INTEGER. La tabla tiene 30M filas. ALTER COLUMN TYPE INTEGER → BIGINT requiere reescritura.
Diseña el expand-contract.
Ver solución
Análisis:
ALTER COLUMN points TYPE BIGINTtomaACCESS EXCLUSIVEy reescribe la columna (cada fila se reescribe con la nueva representación). Para 30M filas: minutos. Inviable en producción.- Aún si fuera rápido, hacerlo "de golpe" rompe coexistencia: la app vieja espera INTEGER, la nueva espera BIGINT.
Patrón: expand-contract con columna nueva.
Deploy 1 — Add new BIGINT column:
def upgrade():
op.execute("SET lock_timeout = '5s'")
op.add_column("tasks", sa.Column("points_v2", sa.BigInteger(), nullable=True))
def downgrade():
op.drop_column("tasks", "points_v2")
Deploy 2 — Code: dual-write to both columns (código):
- INSERTs/UPDATEs escriben tanto en
pointscomo enpoints_v2. - SELECTs siguen leyendo de
points.
class Task(Base):
points: Mapped[int] = mapped_column(sa.Integer) # legacy
points_v2: Mapped[int | None] = mapped_column(sa.BigInteger)
def set_points(self, val: int) -> None:
self.points = val if val < 2_147_483_647 else None # cap a INTEGER si overflow
self.points_v2 = val
Backfill — points_v2 = points (DDL):
def upgrade():
op.execute("SET LOCAL statement_timeout = '0'")
connection = op.get_bind()
BATCH_SIZE = 10_000
SLEEP_SEC = 0.1
max_id = connection.execute(
sa.text("SELECT COALESCE(MAX(id), 0) FROM tasks WHERE points_v2 IS NULL")
).scalar() or 0
batch_min = 0
while batch_min <= max_id:
connection.execute(
sa.text(
"UPDATE tasks SET points_v2 = points "
"WHERE id BETWEEN :a AND :b AND points_v2 IS NULL"
),
{"a": batch_min, "b": batch_min + BATCH_SIZE - 1},
)
batch_min += BATCH_SIZE
time.sleep(SLEEP_SEC)
Deploy 3 — Code: read from points_v2 (código):
- SELECTs ahora leen
points_v2. - INSERTs/UPDATEs siguen escribiendo a ambos.
Deploy 4 — Code: stop writing points (código):
- Solo escribe
points_v2. pointsqueda inmutable.
Deploy 5 — Drop old column (DDL):
def upgrade():
op.execute("SET lock_timeout = '5s'")
op.drop_column("tasks", "points")
Deploy 6 (opcional) — Rename:
def upgrade():
op.execute("SET lock_timeout = '5s'")
# ALTER TABLE ... RENAME COLUMN es metadata-only, instantáneo
op.alter_column("tasks", "points_v2", new_column_name="points")
(Y entonces el código tiene que actualizarse para usar points de nuevo... que es otro deploy. Por eso muchos equipos dejan el _v2 en el nombre permanentemente y absorben la fealdad del nombre por simplicidad operacional.)
Lección: ALTER COLUMN TYPE casi siempre requiere el patrón completo de "columna paralela". Es más laborioso que ADD column nueva, pero es el único camino sin downtime.
Resumen y siguiente paso
En esta cápsula aprendiste:
- Expand-contract es el patrón fundamental para evolucionar schema sin downtime: agregar la nueva forma sin tocar la vieja, migrar datos, swap código, eliminar la vieja al final.
- Las 3 fases canónicas para ADD COLUMN NOT NULL: expand (add nullable), backfill (en batches), contract (set NOT NULL con truco de NOT VALID).
- El rename de columna requiere 4-5 deploys porque la columna vieja debe coexistir con la nueva durante el rolling deploy.
- Cada migration Alembic tiene un upgrade y downgrade que define el window de rollback de esa fase.
- El backfill se hace en batches (10k filas con sleep de 100ms) para no tomar lock prolongado.
- El truco de NOT VALID + VALIDATE evita el full scan bloqueante de SET NOT NULL en tabla grande.
- La window de rollback se cierra en el contract: las primeras fases son reversibles, la última no.
Antes de avanzar deberías poder:
- Diseñar el expand-contract para cualquier ADD COLUMN NOT NULL en tabla grande.
- Escribir las 3 migrations Alembic concretas con upgrade y downgrade de cada una.
- Diseñar el patrón de 4-5 deploys para renombrar columna sin downtime.
- Implementar backfill en batches con verificación final.
- Articular la window de rollback en cada fase del proceso.
- Reconocer que ALTER COLUMN TYPE casi siempre requiere "columna paralela" en lugar de transformación in-place.
Siguiente cápsula — CREATE INDEX CONCURRENTLY y otras operaciones no bloqueantes. Ya dominas la mecánica fundamental del expand-contract. Ahora vas a aprender la operación más importante que se ejecuta sin bloquear producción: CREATE INDEX CONCURRENTLY. Vas a entender el gotcha crítico (no puede ejecutarse en transacción) y cómo resolverlo en Alembic con op.get_context().autocommit_block(). Sin esto, los expand-contract sobre tablas grandes que requieren índices nuevos te van a fallar en producción.
Recursos
- GitLab — Adding columns with default values — el playbook detallado de GitLab sobre ADD COLUMN con DEFAULT.
- Strong Migrations — Backfilling data — el catálogo de patrones para backfills seguros.
- PostgreSQL — ALTER TABLE: notes on NOT NULL — explicación oficial del truco NOT VALID + VALIDATE.
- PlanetScale — Online schema migrations: rename column — cómo PlanetScale resuelve el rename con shadow tables (perspectiva alternativa).
- Alembic — Operation Reference: alter_column — sintaxis completa de alter_column en Alembic.
- Tinybird — How to safely drop columns in PostgreSQL — caso específico de DROP COLUMN seguro.
- PostgreSQL Wiki — Slow Query Questions — diagnóstico de queries lentas que pueden interferir con migrations.
Módulo 5 — SQL Patterns for Production APIs Guide
Siguiente cápsula: CREATE INDEX CONCURRENTLY y otras operaciones no bloqueantes — la operación más importante que se ejecuta sin bloquear producción, con el gotcha crítico de Alembic.