Aprende a realizar cambios de esquema sin tiempo de inactividad con el patrón expandir/contraer: añade columnas con seguridad, backfill en lotes, despliega código compatible y luego elimina las rutas antiguas.

La indisponibilidad por un cambio en la base de datos no siempre es una caída clara y obvia. Para los usuarios puede parecer una página que carga infinitamente, un pago que falla o una app que de repente muestra "algo salió mal". Para los equipos se manifiesta como alertas, aumento de tasas de error y una acumulación de escrituras fallidas que hay que limpiar.
Los cambios de esquema son arriesgados porque la base de datos es compartida por todas las versiones de la app que estén ejecutándose. Durante un despliegue suele haber código viejo y nuevo conviviendo (despliegues rolling, múltiples instancias, jobs en segundo plano). Una migración que parece correcta aún puede romper alguna de esas versiones.
Los fallos habituales incluyen:
Incluso cuando el código está bien, los despliegues quedan bloqueados porque el problema real es la compatibilidad temporal entre versiones. Los cambios sin tiempo de inactividad siguen una regla: cada estado intermedio debe ser seguro para código viejo y nuevo. Cambias la base de datos sin romper lecturas ni escrituras existentes, despliegas código que puede manejar ambas formas y eliminas la ruta antigua solo cuando nadie depende ya de ella.
Ese esfuerzo extra vale la pena cuando tienes tráfico real, SLAs estrictos o muchas instancias y workers. Para una herramienta interna pequeña con poca actividad, una ventana de mantenimiento planificada puede ser más simple.
La mayoría de los incidentes por trabajo de base de datos ocurren porque la app espera que el cambio sea instantáneo, mientras que el cambio en la base de datos tarda. El patrón expandir/contraer evita eso dividiendo un cambio arriesgado en pasos más pequeños y seguros.
Durante un tiempo, tu sistema soporta dos "dialectos" a la vez. Introduces la nueva estructura primero, mantienes la antigua en funcionamiento, mueves datos gradualmente y luego limpias.
El patrón es simple:
Esto encaja bien con despliegues rolling. Si actualizas 10 servidores uno a uno, durante un tiempo tendrás versiones viejas y nuevas juntas. Expandir/contraer mantiene ambas compatibles con la misma base de datos durante ese solapamiento.
También hace que los rollbacks sean menos peligrosos. Si una nueva versión tiene un bug, puedes revertir la app sin revertir la base de datos, porque las estructuras antiguas aún existen durante la ventana de expandir.
Ejemplo: quieres dividir una columna full_name de PostgreSQL en first_name y last_name. Añades las nuevas columnas (expandir), publicas código que puede leer y escribir ambas formas, haces el backfill de filas antiguas y luego eliminas full_name cuando estés seguro de que nadie lo usa (contraer).
La fase de expandir trata de añadir nuevas opciones, no de quitar las antiguas.
Un movimiento común es añadir una nueva columna. En PostgreSQL, normalmente es más seguro añadirla como nullable y sin default. Añadir una columna no nula con default puede provocar una reescritura de la tabla o bloqueos más fuertes, según la versión de Postgres y el cambio exacto. Una secuencia más segura es: añadir nullable, desplegar código tolerante, backfill y luego aplicar NOT NULL.
Los índices también requieren cuidado. Crear un índice normal puede bloquear escrituras más tiempo del esperado. Cuando sea posible, usa la creación de índices concurrente para que lecturas y escrituras sigan fluyendo. Tarda más, pero evita bloqueos que detengan el release.
Expandir también puede significar añadir tablas nuevas. Si pasas de una sola columna a una relación many-to-many, puedes añadir una tabla de unión manteniendo la columna antigua. La ruta antigua sigue funcionando mientras la nueva estructura empieza a recoger datos.
En la práctica, expandir suele incluir:
Tras expandir, las versiones viejas y nuevas de la app deberían poder ejecutarse al mismo tiempo sin sorpresas.
La mayor parte del dolor en releases ocurre en el medio: algunos servidores ejecutan código nuevo, otros siguen con el antiguo, mientras la base de datos ya está cambiando. Tu objetivo es claro: cualquier versión durante el despliegue debe funcionar con el esquema viejo y el expandido.
Un enfoque común es el dual-write. Si añades una nueva columna, la app nueva escribe en la columna antigua y en la nueva. Las versiones antiguas siguen escribiendo solo en la antigua, que sigue existiendo. Mantén la nueva columna opcional al principio y retrasa las restricciones estrictas hasta que estés seguro de que todos los escritores se han actualizado.
Las lecturas suelen cambiarse con más cuidado que las escrituras. Durante un tiempo, mantén las lecturas en la columna antigua (la que sabes que está completamente poblada). Tras el backfill y la verificación, cambia las lecturas para preferir la columna nueva, con fallback a la antigua si la nueva está vacía.
También conserva estable la salida de tu API mientras la base de datos cambia por debajo. Aunque introduzcas un campo interno nuevo, evita cambiar la forma de las respuestas hasta que todos los consumidores estén listos (web, móvil, integraciones).
Un despliegue amigable con rollback suele verse así:
La idea clave es que el primer paso irreversible es eliminar la estructura antigua, así que lo dejas para el final.
El backfill es donde muchas migraciones "sin tiempo de inactividad" fallan. Quieres rellenar la nueva columna para filas existentes sin locks largos, consultas lentas ni picos de carga inesperados.
Los lotes importan. Apunta a batches que terminen rápido (segundos, no minutos). Si cada lote es pequeño, puedes pausar, reanudar y ajustar el job sin bloquear releases.
Para seguir el progreso, usa un cursor estable. En PostgreSQL eso suele ser la clave primaria. Procesa filas en orden y guarda el último id completado, o trabaja por rangos de id. Esto evita escaneos completos de tabla caros cuando el job se reinicia.
Aquí hay un patrón simple:
UPDATE my_table
SET new_col = ...
WHERE new_col IS NULL
AND id > $last_id
ORDER BY id
LIMIT 1000;
Haz la actualización condicional (por ejemplo, WHERE new_col IS NULL) para que el job sea idempotente. Las reejecuciones solo tocarán filas que aún necesitan trabajo, lo que reduce escrituras innecesarias.
Planifica que lleguen datos nuevos durante el backfill. El orden habitual es:
Un buen backfill es aburrido: constante, medible y fácil de pausar si la base de datos se calienta.
El momento más arriesgado no es añadir la columna nueva. Es decidir que puedes confiar en ella.
Antes de pasar a contract, demuestra dos cosas: los datos nuevos están completos y la producción los ha estado leyendo sin problemas.
Empieza con comprobaciones de completitud rápidas y repetibles:
Si estás dual-writing, añade una comprobación de consistencia para atrapar bugs silenciosos. Por ejemplo, ejecuta una consulta horaria que encuentre filas donde old_value <> new_value y alerta si no es cero. A menudo es la manera más rápida de descubrir que algún writer sigue actualizando solo el campo antiguo.
Vigila señales básicas en producción mientras corre la migración. Si el tiempo de consulta o las esperas por locks suben, hasta tus consultas de verificación “seguras” pueden estar añadiendo carga. Supervisa las tasas de error de cualquier camino que lea la columna nueva, especialmente justo después de despliegues.
¿Cuánto debes mantener ambos caminos? El tiempo suficiente para sobrevivir al menos un ciclo completo de releases y una reejecución del backfill. Muchos equipos usan 1–2 semanas, o hasta estar seguros de que no queda ninguna versión antigua ejecutándose.
Contract es donde los equipos se ponen nerviosos porque parece el punto sin retorno. Si expand se hizo bien, contract es mayormente limpieza y puedes hacerlo en pasos pequeños y de bajo riesgo.
Elige el momento con cuidado. No elimines nada justo después de terminar un backfill. Dale al menos un ciclo completo de releases para que jobs retrasados y casos extremos tengan tiempo de aparecer.
Una secuencia de contract segura suele ser:
Si puedes, divide contract en dos releases: uno que elimina referencias en el código (con logging extra) y otro posterior que elimina objetos de la base de datos. Esa separación facilita rollback y troubleshooting.
Los detalles de PostgreSQL importan aquí. Dropear una columna es mayormente un cambio de metadatos, pero aun así requiere un ACCESS EXCLUSIVE lock breve. Planifica una ventana tranquila y mantén la migración rápida. Si creaste índices extra, prefiere DROP INDEX CONCURRENTLY para no bloquear escrituras (no puede ejecutarse dentro de un bloque de transacción, así que tu tooling de migraciones debe soportarlo).
Las migraciones sin tiempo de inactividad fallan cuando la base de datos y la app dejan de ponerse de acuerdo sobre lo permitido. El patrón solo funciona si cada estado intermedio es seguro para código viejo y nuevo.
Estos errores aparecen con frecuencia:
Un escenario realista: empiezas a escribir full_name desde la API, pero un job en segundo plano que crea usuarios solo establece first_name y last_name. Ese job corre de noche, inserta filas con full_name = NULL y luego el código asumirá que full_name siempre está presente.
Trata cada paso como un release que puede durar días:
Una checklist repetible evita que publiques código que solo funciona en un estado de base de datos.
Antes de desplegar, confirma que la base de datos ya tiene las piezas expandidas (nuevas columnas/tablas, índices creados de forma de bajo bloqueo). Luego confirma que la app es tolerante: debe funcionar contra la forma antigua, la expandida y un estado a medio backfill.
Mantén la checklist corta:
Una migración solo se considera hecha cuando las lecturas usan los datos nuevos, las escrituras ya no mantienen los datos antiguos y has verificado el backfill con al menos una comprobación simple (conteos o muestreo).
Imagina una tabla customers en PostgreSQL con una columna phone que guarda valores desordenados (diferentes formatos, a veces vacía). Quieres reemplazarla por phone_e164, pero no puedes bloquear releases ni apagar la app.
Una secuencia limpia expandir/contraer sería:
phone_e164 como nullable, sin default y sin restricciones pesadas aún.phone como phone_e164, pero deja las lecturas en phone para que nada cambie para los usuarios.phone_e164 primero y haga fallback a phone si sigue siendo NULL.phone_e164, elimina el fallback, borra phone y luego añade restricciones más estrictas si las necesitas.El rollback sigue siendo sencillo cuando cada paso es retrocompatible. Si el cambio de lectura causa problemas, revierte la app y la BD seguirá teniendo ambas columnas. Si el backfill provoca picos de carga, pausa el job, reduce el tamaño de los lotes y continúa más tarde.
Si quieres que el equipo mantenga alineamiento, documenta el plan en un solo lugar: el SQL exacto, qué release cambia lecturas, cómo mides la finalización (por ejemplo, porcentaje de phone_e164 no NULL) y quién es responsable de cada paso.
Expandir/contraer funciona mejor cuando se vuelve rutinario. Escribe un runbook corto que el equipo pueda reutilizar para cada cambio de esquema, idealmente de una página y lo suficientemente específico como para que un nuevo compañero lo siga.
Una plantilla práctica cubre:
Decide la propiedad desde el principio. “Todos pensaban que alguien más haría contract” es cómo columnas antiguas y feature flags viven meses.
Aunque el backfill sea online, prográmalo cuando el tráfico sea menor. Es más fácil mantener lotes pequeños, vigilar la carga y parar rápido si la latencia sube.
Si despliegas con Koder.ai (koder.ai), Planning Mode puede ser una forma útil de mapear fases y puntos de control antes de tocar producción. Las mismas reglas de compatibilidad se aplican, pero tener los pasos escritos hace más difícil saltarse las partes "aburridas" que evitan las interrupciones.
Porque tu base de datos la usan todas las versiones en ejecución de tu aplicación. Durante despliegues con rotación y trabajos en segundo plano pueden convivir código antiguo y nuevo, y una migración que cambia nombres, elimina columnas o añade restricciones puede romper la versión que no espera ese estado exacto del esquema.
Significa diseñar la migración de modo que cada estado intermedio de la base de datos funcione tanto para el código antiguo como para el nuevo. Primero añades las nuevas estructuras, mantienes ambos caminos activos durante un tiempo y solo eliminas las estructuras antiguas cuando nada depende ya de ellas.
Expandir añade nuevas columnas, tablas o índices sin quitar nada que la aplicación actual necesite. Contraer es la fase de limpieza en la que eliminas las columnas antiguas, las lecturas/escrituras antiguas y la lógica temporal de sincronización después de comprobar que la nueva ruta funciona por completo.
Empezar con una columna nullable sin default suele ser lo más seguro, porque evita reescrituras de tabla y bloqueos pesados. Luego despliegas código que tolere que la columna falte o sea NULL, haces el backfill gradualmente y solo después endureces restricciones como NOT NULL.
Cuando la nueva versión de la app escribe tanto en el campo antiguo como en el nuevo mientras dura la transición. Así mantienes consistencia aunque haya instancias antiguas que solo conozcan el campo anterior.
Haz el backfill en lotes pequeños que terminen rápidamente, y asegúrate de que cada lote sea idempotente para que las reejecuciones solo toquen filas que siguen sin completar. Vigila tiempos de consulta, esperas por locks y lag de replicación, y prepárate para pausar o reducir el tamaño del lote si la base de datos se calienta.
Primero comprueba la completitud, por ejemplo cuántas filas siguen con NULL en la nueva columna. Luego haz una comprobación de consistencia comparando valores antiguos y nuevos en una muestra (o continuamente si es barato) y vigila errores en producción justo después de los despliegues para detectar rutas que sigan usando el esquema equivocado.
NOT NULL o nuevas restricciones puestas demasiado pronto pueden bloquear escrituras; hacer un backfill enorme en una sola transacción puede mantener locks y causar bloat; algunos defaults disparan reescrituras de tabla en PostgreSQL; y cambiar lecturas al nuevo campo antes de que las escrituras lo llenen puede provocar inconsistencias. También suelen olvidarse writers/lectores externos como cron jobs, workers o consultas de reporting.
Cuando dejas de escribir en el campo antiguo, cambias las lecturas al nuevo sin usar fallback y esperas el tiempo suficiente para estar seguro de que no quedan versiones antiguas o workers en ejecución. Muchas equipos hacen la eliminación de objetos de base de datos en una release separada para facilitar rollback.
Si puedes permitir una ventana de mantenimiento y hay poco tráfico, una migración directa puede bastar. Pero si tienes usuarios reales, múltiples instancias, trabajos en segundo plano o un SLA, expand/contract compensa el esfuerzo extra porque mantiene despliegues y rollback más seguros; en Koder.ai Planning Mode, escribir las fases y comprobaciones por adelantado ayuda a no saltarse los pasos "aburridos" que evitan las interrupciones.