Casi cualquier equipo te dirá que sus migraciones son reversibles. Pruébalo un martes por la tarde, con tráfico real, delante de un CTO que mira. La respuesta suele ser "bueno, esta sí, pero…". La razón por la que la mayoría de las migraciones no se pueden revertir de verdad es que nadie las diseña para eso. Las diseñan para ir hacia adelante, y luego escriben def downgrade() porque el framework lo pide.
Hemos pasado muchas noches en el lado equivocado de una migración que "debería" haberse revertido. Los patrones que vienen a continuación son los que ahora aplicamos por defecto. No son nuevos. Son la disciplina a la que los equipos production-grade convergen después de su segunda o tercera mala noche. El objetivo de este post es ahorrarte algunas.
La restricción que mantenemos en todo momento: cualquier migración que enviamos tiene que ser reversible sin perder datos, en una flota multi-instancia, bajo carga. No en teoría. Un martes.
Adiciones de columna en dos fases
El modo de fallo más común es la migración de un solo paso que añade una columna, hace el backfill, y empieza a leer de ella en el mismo deploy. Funciona en un portátil. Funciona en staging con una instancia. Se rompe en el momento en que la flota tiene más de un nodo y el rollout es gradual.
El patrón que aguanta: cinco deploys, no uno.
- Solo schema. Añade la nueva columna, nullable, sin restricciones. El código antiguo sigue corriendo sin cambios. Este deploy es trivialmente reversible porque nada lee ni escribe la nueva columna todavía.
- Doble escritura. Despliega código que escribe tanto en la columna antigua como en la nueva en cada mutación. Las lecturas siguen viniendo de la columna antigua. La nueva columna empieza a ponerse al día con las filas nuevas.
- Backfill. Ejecuta un job separado e idempotente que rellena la nueva columna para las filas históricas. Y esto es clave: es un job, no una migración — puede pausarse, reanudarse y observarse. Volveremos a esto.
- Cambia las lecturas. Despliega código que lee de la nueva columna. La columna antigua se sigue escribiendo como red de seguridad. Si leer la nueva columna explota, reviertes este único deploy y el sistema vuelve a un estado conocido y bueno en minutos.
- Retira la columna antigua. Una vez que la nueva columna ha sido el camino de lectura el tiempo suficiente como para confiar en ella, descarta la columna antigua en una migración final.
Cinco deploys para un cambio lógico. El intercambio es que cualquiera de los cuatro deploys reversibles puede revertirse de forma aislada. El quinto — el drop — es el único paso que realmente compromete, y para entonces tienes semanas de evidencia de que la nueva columna es correcta.
La disciplina cuesta tiempo por migración. A cambio, recupera la capacidad de desplegar a las 2pm en vez de a las 2am.
Los renames son deletes-and-adds
Nunca renombres una columna en una sola migración. Ni en Postgres, ni en MySQL, ni en nada que ejecute más de una instancia de tu aplicación a la vez.
La razón es la flota durante un rolling deploy. La instancia A sigue leyendo customer_name. La instancia B tiene el código nuevo y renombró la columna a full_name. Durante los pocos minutos que dura el rollout, la mitad de tu flota está leyendo una columna que ya no existe. La otra mitad está escribiendo una que la base de datos no tiene.
Un rename es la adición de columna en dos fases descrita arriba, con un paso extra: copia los datos durante la fase de doble escritura. La "columna antigua" es el nombre original. La "columna nueva" es el nombre nuevo. Cinco deploys, igual que antes. El rename es conceptualmente el cambio más inocente que puedes hacer y operacionalmente uno de los más peligrosos, precisamente porque parece muy seguro en un entorno de dev de una sola instancia.
Si un ingeniero junior del equipo renombra una columna en una sola migración, no es un nit de code review. Es un momento de aprendizaje sobre cómo es un deploy de verdad en producción.
Los backfills van separados de los cambios de schema
Un cambio de schema que añade una columna es, con los flags adecuados, casi instantáneo y completamente reversible. Un backfill que actualiza millones de filas no lo es ni de lejos. Tratarlos como el mismo deploy es como acabas con una migración que lleva cuarenta minutos corriendo y no tienes ni idea de si esperar o matarla.
La regla que mantenemos: las migraciones de schema son aditivas y rápidas. Los backfills son jobs.
Una migración de schema que tarda más de un segundo o dos en la base de datos de producción es una bandera roja. Los backfills de verdad van en una cola (Celery, un management command puntual, lo que use el equipo) con tres propiedades: idempotente, reanudable, observable. Queremos ver el progreso. Queremos poder pausarlo durante un pico de tráfico. Queremos saber que terminó, y que terminó correctamente.
Concretamente, esto significa que una feature aterriza en tres fases lógicas:
- Fase 1: migración de schema — aditiva, rápida, reversible.
- Fase 2: cambio de código que usa la nueva forma, con la red de seguridad de doble escritura.
- Fase 3: el backfill, ejecutado como job, observado hasta completarse.
Cada fase es reversible de forma independiente. Si el backfill resulta estar mal, paras el job, arreglas la lógica y lo vuelves a correr. No reviertes el cambio de schema. No redespliegas la aplicación. El radio de explosión de un error se queda dentro de la fase donde vive el error.
Endurecer una restricción es su propio deploy
Añadir NOT NULL a una columna que se añadió dos deploys atrás se siente como una continuación del mismo cambio. No lo es. Es un deploy aparte, y debería ser el último de la serie, no agrupado con la adición de columna.
Por qué importa: cuando una restricción se endurece, la superficie de "lo que puede romperse" se expande. Cualquier ruta de código que produzca un NULL para esa columna — incluyendo código que estaba perfectamente feliz haciéndolo hace una semana — ahora dará error. Incluyendo, a menudo, rutas que nadie recordaba que existían.
El orden correcto:
- Añade la columna, nullable, con un default razonable para las escrituras nuevas.
- Despliega código de aplicación que siempre popula la columna.
- Haz backfill de las filas históricas que se insertaron antes del paso 2.
- Solo entonces añade la restricción
NOT NULL.
Cada paso es reversible de forma independiente. El deploy que endurece la restricción es el más arriesgado de la serie, y merece su propia ventana de deploy precisamente porque es el que más probablemente saque a la luz un desconocido desconocido. Si falla, reviertes la restricción, la aplicación sigue funcionando, y tienes una señal limpia de qué arreglar.
La versión de esta regla que les grabamos a los ingenieros junior: una restricción es un deployment, no una migración.
Foreign keys con NOT VALID y luego VALIDATE
El caso específico de Postgres que conviene conocer. Añadir una foreign key a una tabla grande parece inocente y toma un lock exclusivo el tiempo suficiente como para tirar abajo la aplicación. Hemos visto que ocurre. Dos veces.
El patrón que no lo hace:
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers(id)
NOT VALID;
NOT VALID le dice a Postgres que aplique la restricción a las filas nuevas pero que no escanee las existentes. El lock es breve. La migración termina en milisegundos.
Y después, por separado:
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;
VALIDATE toma un lock mucho más débil y escanea las filas existentes en segundo plano. Puede ejecutarse en una hora tranquila. Si falla — porque alguna fila histórica tiene un customer_id colgando — te enteras y arreglas los datos, no el schema. La restricción ya está protegiendo las escrituras nuevas.
Dos migraciones, un cambio lógico, sin outage en producción. Este patrón generaliza: cualquier restricción que requiera un table scan debería añadirse con el equivalente a "no escanees las filas existentes todavía", y validarse aparte.
La migración que genuinamente no se puede revertir
No vamos a afirmar que toda migración se pueda revertir. Algunas son genuinamente de un solo sentido. Borrar una columna es de un solo sentido. Borrar una tabla es de un solo sentido. Colapsar dos valores de enum en uno es de un solo sentido, y encima con pérdida. Dividir una columna en tres después de un cambio de parsing es, en la práctica, de un solo sentido.
La disciplina no consiste en fingir que no existen. Consiste en nombrarlas, por adelantado, por escrito.
Nuestra regla: cualquier migración genuinamente no reversible requiere un ADR corto antes de merge. El ADR dice, en tres o cuatro bullets:
- Qué cambia, y qué se pierde.
- Por qué nos comprometemos a ella (es decir, por qué una migración en dos fases no está disponible).
- Cuál es el plan de recuperación si el deploy sale mal (normalmente: restaurar de backup, reproducir X horas de escrituras).
- La ventana de deploy en la que la programamos.
Esto no es papeleo por el papeleo. Es el momento en que alguien podría darse cuenta de que la migración en realidad no necesitaba ser de un solo sentido, y el equipo puede elegir el camino aburrido y reversible. Aproximadamente un tercio de las veces, eso es lo que pasa.
Cuando el ADR concluye que la migración sí es de un solo sentido, el equipo entra con los ojos abiertos. El deploy tiene una ventana real, ojos reales en los dashboards, y una ruta de restore probada. El coste de una migración irreversible es la planificación a su alrededor, pagada por adelantado.
El trade-off
Todo esto es más trabajo por migración. Una feature que habría sido una sola migración se convierte en tres. Un rename de columna se convierte en una saga de cinco deploys. El equipo tiene que ir lo bastante despacio como para pensar en qué fase está.
El intercambio es que "la migración se rompió y revertimos" deja de ser una emergencia de sábado y se convierte en un incidente de cinco minutos en mitad de una jornada normal. El coste se paga en incrementos pequeños y predecibles durante la planificación. El coste no se paga en una sola noche catastrófica con todo el equipo en una llamada intentando recordar si el backup es consistente y cuánto tarda el replay.
Hemos estado en ambos lados de este trade. El primero es genuinamente mejor. No está ni cerca.
Cierre
La mayoría de los equipos descubren esta disciplina después de la mala noche, en retrospectiva. El post-mortem es honesto, las lecciones son afiladas, y la siguiente migración es meticulosa. La de después también es meticulosa. La tercera ya un poco menos, y para la sexta el equipo ha vuelto a los cambios de un solo paso que "deberían estar bien".
Nosotros intentamos aprender esta disciplina de las malas noches de otros. Los patrones de arriba no son nuestros. Son la respuesta convergente a la que llegan los equipos production-grade después de suficientes sábados dolorosos. El objetivo es saltarse los sábados y quedarse con la respuesta.
Si te llevas una sola cosa de este post: separa los cambios de schema, los cambios de código, los backfills y los cambios de restricción en deploys distintos, y recuperarás la mayor parte de la reversibilidad que creías tener.


