Migraciones de Base de Datos sin Downtime con Prisma
prisma migrate deploy en una tabla de 50 millones de registros es decir "qué podría salir mal" en producción. Esa migración tan inocente que localmente tomó 200 milisegundos, en producción con un índice que se reconstruye, una columna que se llena con default, y requests concurrentes golpeando la misma tabla, puede tomar 40 minutos de lock exclusivo. Cuarenta minutos durante los cuales ningún usuario puede loguearse, ningún pago se procesa, y el canal de Slack de operaciones se llena de capturas de pantalla de errores 500.
El problema no es Prisma. Es cualquier migración naive contra una tabla grande en producción. Postgres y MySQL tienen operaciones que bloquean la tabla completa mientras corren: agregar una columna con default no nullable, cambiar el tipo de una columna, renombrar algo que está referenciado. Si corrés esas operaciones contra una tabla de millones de filas y un load balancer que no para de mandar tráfico, el sistema se congela.
El patrón que resuelve esto no es nuevo. Se llama expand-contract (también conocido como parallel change) y es la técnica estándar que usan equipos como GitHub, Shopify y Stripe para cambiar esquemas sin cortar el servicio. La idea: cada migración se divide en cuatro fases pequeñas que individualmente son seguras, corren en segundos, y se pueden desplegar gradualmente con feature flags.
En este artículo aprenderás:
- Qué operaciones bloquean la tabla en PostgreSQL y cuáles no (la lista corta y exacta)
- El patrón expand-contract en 4 fases: Expand → Migrate → Contract → Cleanup, con ejemplo real en Prisma
- Backfill batched para mover datos de una columna vieja a una nueva sin saturar la base de datos
- Rollback seguro en cada fase si algo falla a mitad del deploy
- Checklist de migración que usamos antes de tocar producción
Qué operaciones bloquean y cuáles no
Postgres 12+ hizo grandes mejoras: ALTER TABLE ADD COLUMN sin default ya no rescribe la tabla, ALTER TABLE ADD COLUMN DEFAULT <const> en Postgres 11+ tampoco. Pero varias operaciones todavía toman un ACCESS EXCLUSIVE LOCK que bloquea lecturas y escrituras:
Bloquean la tabla completa (nunca directamente en producción):
ALTER COLUMN TYPEcuando cambia el tipo real de datos almacenados (por ejemploVARCHAR(50)aVARCHAR(100)está bien, peroINTaBIGINTrescribe todo)ADD COLUMN ... NOT NULLsin defaultDROP COLUMN(rápido pero todavía toma lock)ADD PRIMARY KEYsobre columna existenteADD CONSTRAINT ... NOT VALIDseguido deVALIDATE CONSTRAINTcuando la tabla es grande
Seguras en producción:
ADD COLUMN(nullable, sin default o default constante en Postgres 11+)CREATE INDEX CONCURRENTLY(no bloquea escrituras, tarda más pero no congela el sistema)DROP INDEX CONCURRENTLYALTER TABLE ... SET DEFAULT(solo afecta inserts futuros)COMMENT ON
La regla simple: si tu migración incluye operaciones de la primera lista, no la corrás directo. Necesita expand-contract.
Aun operaciones "rápidas" pueden bloquear si hay una transacción larga activa que tiene un row lock sobre la tabla. El ALTER TABLE entra en cola detrás de esa transacción Y detrás de todas las transacciones que llegan después. Configurá lock_timeout = 2s en tus migraciones para que fallen rápido en lugar de bloquear todo.
El patrón expand-contract en 4 fases
El caso de estudio: tenés una tabla users con columna email de tipo VARCHAR(100) y 50 millones de registros. Querés renombrarla a contact_email y extender el tamaño a VARCHAR(255). Un ALTER COLUMN RENAME directo tomaría segundos, pero cualquier código que siga leyendo la columna vieja crashea. Y no podés actualizar todos los servicios al mismo tiempo.
El patrón expand-contract divide esto en cuatro fases, cada una desplegable independientemente:
Fase 1 — Expand. Agregamos la columna nueva contact_email nullable, sin tocar la vieja. La aplicación sigue leyendo y escribiendo sobre email.
Fase 2 — Migrate. Desplegamos código que escribe a ambas columnas simultáneamente (dual write) y sigue leyendo de la vieja. Corrés un backfill batched para copiar email → contact_email en los 50M registros existentes.
Fase 3 — Contract. Desplegamos código que lee de la nueva columna contact_email. La vieja sigue recibiendo escrituras por seguridad.
Fase 4 — Cleanup. Después de verificar 24-48 horas que todo funciona con la columna nueva, desplegamos código que deja de escribir a la vieja. Después corremos DROP COLUMN email.
Cada fase es un deploy pequeño, reversible, sin downtime. Si algo falla en la fase 3, rollback a fase 2 (sigue dual write, todos los datos ahí). Si algo falla en la fase 4, rollback a fase 3 (la columna vieja está intacta aunque sin escrituras recientes, pero tenés dual write activado de nuevo en segundos).
Fase 1: Expand en Prisma
Modificamos el schema:
// prisma/schema.prisma
model User {
id BigInt @id @default(autoincrement())
email String @db.VarChar(100)
contactEmail String? @map("contact_email") @db.VarChar(255)
createdAt DateTime @default(now())
@@map("users")
}
Generamos la migración y la corremos:
npx prisma migrate dev --name expand_add_contact_email
El SQL generado es un simple ALTER TABLE users ADD COLUMN contact_email VARCHAR(255); que es instantáneo en Postgres 11+ porque es nullable y sin default. Podés desplegarla en horario pico sin preocupación.
Fase 2: Dual write + backfill
Modificamos el código de la aplicación para escribir a ambas columnas:
// antes
await prisma.user.update({
where: { id },
data: { email: newEmail },
});
// despues (durante fase 2)
await prisma.user.update({
where: { id },
data: {
email: newEmail,
contactEmail: newEmail, // dual write
},
});
Desplegamos. A partir de este momento, todos los updates nuevos llenan ambas columnas. Faltan los 50M registros históricos.
El backfill debe correr en lotes pequeños para no saturar la base de datos. Un script típico en TypeScript:
// scripts/backfill-contact-email.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const BATCH_SIZE = 1000;
const PAUSE_MS = 50; // dejar respirar a la DB
async function backfill() {
let lastId = 0n;
while (true) {
const batch = await prisma.user.findMany({
where: {
id: { gt: lastId },
contactEmail: null,
},
select: { id: true, email: true },
orderBy: { id: 'asc' },
take: BATCH_SIZE,
});
if (batch.length === 0) break;
await prisma.$transaction(
batch.map((u) =>
prisma.user.update({
where: { id: u.id },
data: { contactEmail: u.email },
}),
),
);
lastId = batch[batch.length - 1].id;
console.log(`[backfill] hasta id=${lastId}, batch=${batch.length}`);
await new Promise((r) => setTimeout(r, PAUSE_MS));
}
console.log('[backfill] done');
}
backfill().finally(() => prisma.$disconnect());
Tres decisiones importantes. Pagination por id > lastId en lugar de OFFSET, porque OFFSET se vuelve O(N) en tablas grandes. WHERE contactEmail IS NULL filtra los registros ya migrados, permitiendo reanudar el script si se interrumpe. PAUSE_MS entre lotes le da aire a la DB para atender tráfico de producción sin saturar.
Con lotes de 1,000 y pausa de 50ms, 50 millones de registros tardan aproximadamente 40-45 minutos. Si la base de datos está holgada podés subir a lotes de 5,000 con 20ms de pausa y bajás a 10 minutos. Monitorealo con el dashboard que cubrimos en Health-Check de un Backend Node.js.
¿Tenés una migración grande pendiente?
Revisamos tu plan de migración en 45 min por Meet antes de que la ejecutes. Te decimos dónde bloquea, cómo partirla en expand-contract, y qué rollback preparar. Sin pitch de ventas.
Agendar revisión de migración →Fase 3: Switch de lectura
Una vez que el backfill terminó y verificaste los datos (más abajo el cómo), despliegás código que lee de contactEmail en lugar de email:
// lectura cambia de email a contactEmail
const user = await prisma.user.findUnique({
where: { id },
select: { contactEmail: true }, // antes: email
});
Mantené el dual write. En esta fase la columna vieja sigue siendo tu póliza de seguro. Si algo raro aparece (un caso de uso olvidado, un edge case con NULL), rollback a la versión anterior que lee de email y todo sigue funcionando.
Fase 4: Cleanup
Después de 24-48 horas estables en fase 3, desplegás código que deja de escribir a email:
// ya no escribimos email
await prisma.user.update({
where: { id },
data: { contactEmail: newEmail },
});
Y finalmente, la migración que elimina la columna vieja:
// prisma/schema.prisma
model User {
id BigInt @id @default(autoincrement())
contactEmail String @map("contact_email") @db.VarChar(255)
// email removida
createdAt DateTime @default(now())
@@map("users")
}
npx prisma migrate dev --name contract_drop_email
DROP COLUMN en Postgres es rápido (metadatos), pero todavía toma ACCESS EXCLUSIVE LOCK. Corrélo en horario valle o configurá lock_timeout para que falle rápido si hay contención.
Verificar el backfill antes del switch
Nunca cambies la lectura sin verificar que los datos migrados son idénticos a los originales. Una query de validación rápida:
SELECT COUNT(*)
FROM users
WHERE email IS NOT NULL
AND (contact_email IS NULL OR contact_email != email);
El resultado debe ser 0. Si es cualquier otro número, tenés inconsistencia y el backfill no terminó bien. Las causas típicas: el script se mató a la mitad y no se reanudó, o un update durante la ventana de backfill corrió cuando solo una columna tenía el valor nuevo.
Para ese último caso, en la fase 2 conviene correr un trigger de validación que fuerce la igualdad durante el dual write:
CREATE OR REPLACE FUNCTION sync_email_to_contact_email()
RETURNS TRIGGER AS $$
BEGIN
NEW.contact_email = COALESCE(NEW.contact_email, NEW.email);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_email_trigger
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_email_to_contact_email();
Esto garantiza que cualquier escritura que por error se haya olvidado del dual write a nivel aplicación, se corrige en la base de datos. Después del cleanup, quitás el trigger.
Cuándo cambia el tipo, no solo el nombre
El mismo patrón aplica cuando cambiás el tipo de una columna, por ejemplo INT a BIGINT por rebasar el rango de 2 mil millones, o VARCHAR(50) a TEXT. La diferencia es que la columna nueva tiene el tipo nuevo, el backfill hace el cast, y durante la fase 2 dual write convertís al escribir.
Para cambios que no se pueden representar (pasar VARCHAR a JSON, por ejemplo), el backfill parse el string viejo y guarda el JSON en la nueva columna. La clave es que el modelo mental es idéntico: dos columnas coexisten mientras migras.
¿Tu DB está creciendo y ya ves el límite del tipo actual?
Te armamos el plan de migración en una sesión técnica de 60 min. Incluye: SQL de cada fase, script de backfill batched, rollback paso a paso y checklist para correr en producción. Gratis, sin pitch.
Agendar plan de migración →Rollback en cada fase
Expand-contract solo es seguro si cada fase tiene rollback explícito. La tabla de contingencia:
| Fase | Falla | Rollback |
|---|---|---|
| 1 Expand | Migración falla o app no arranca | DROP COLUMN contact_email — la app vieja no la conoce |
| 2 Dual write | Bug en código | Deploy anterior que solo escribe email |
| 2 Backfill | Script se rompe | Reanudable por WHERE contact_email IS NULL |
| 3 Switch lectura | Datos inconsistentes detectados | Deploy anterior que lee email (todavía tiene los valores) |
| 4 Stop dual write | Bug detectado | Re-deploy fase 3 con dual write |
| 4 Drop column | Antes de confirmar estabilidad | Tenés el backup de ayer, restaurá la columna de ahí (molesto pero recuperable) |
Durante todo el proceso, no ejecutar DROP COLUMN hasta 48 horas después de que la fase 3 esté estable. Ese paso es el único realmente irreversible sin restaurar de backup.
Checklist de migración a producción
Esto es lo que pedimos completado antes de correr cualquier migración contra una tabla de > 1M filas:
- Plan escrito de las 4 fases con SQL exacto de cada una
- Estimación de tiempo del backfill calculada con
EXPLAIN ANALYZEen muestra - Ventana de backfill identificada (usualmente noche o fin de semana)
- Rollback documentado para cada fase con comando exacto
- Monitoreo activo (latencia, pool de conexiones, I/O de disco) durante el backfill
- Feature flag o variable de entorno que permite apagar el dual write si el código tiene bug
- Verificación por consulta SQL que compare columnas antes del switch de lectura
- Backup completo reciente (menos de 6 horas) antes de empezar
- Pair programming en producción: dos personas mirando el deploy, una ejecuta, otra monitorea
- Comunicación al equipo en Slack antes de empezar y al terminar cada fase
Este checklist no es teórico: son pasos que han evitado incidentes reales. La migración que más miedo da es la que arrancás a las 2 am solo "para no molestar a nadie". Esa es exactamente la que necesita dos pares de ojos.
Conclusión
Migraciones de base de datos sin downtime no son magia ni requieren herramientas pagadas. Son disciplina: dividir el cambio en fases reversibles, mover datos en lotes, verificar antes de cada switch, y no apurar la limpieza. El patrón expand-contract es la implementación concreta de esa disciplina.
Lo que cubrimos:
- ✅ Qué operaciones bloquean tabla completa en Postgres
- ✅ Las 4 fases del patrón expand-contract con Prisma
- ✅ Backfill batched reanudable con Prisma
- ✅ Triggers de validación durante dual write
- ✅ Rollback explícito en cada fase
- ✅ Checklist pre-migración a producción
¿Tenés una migración crítica próxima?
Revisamos tu plan de migración en 45 min por Meet antes de que toquen producción. Dos devs senior revisando: fases, rollback, backfill, timing. Te dejamos el playbook escrito. Gratis, sin pitch.
Agendar revisión de migración →