Migraciones de bases de datos sin miedo: estrategias para producción
Por qué una migración de base de datos sale mal casi siempre por el mismo motivo, y cómo el patrón expand-contract lo evita sin necesitar una ventana de mantenimiento.
Casi ninguna migración de base de datos sale mal por un error de sintaxis SQL. Sale mal porque alguien asumió que el esquema y el código de la aplicación cambian a la vez, de forma atómica, en el mismo instante para todas las instancias en producción. En un despliegue real eso casi nunca es verdad: durante un rolling deploy hay dos versiones de la aplicación corriendo a la vez contra la misma base de datos, y si la versión nueva espera una columna que la versión vieja todavía no conoce (o al revés), algo se rompe para una fracción de las peticiones, justo la fracción más difícil de reproducir en local.
Este artículo trata sobre cómo evitar ese problema de raíz: la diferencia entre migraciones aditivas y destructivas, el patrón expand-contract que separa el cambio de esquema del cambio de código en pasos independientes y reversibles, y el papel de las herramientas de migración tipadas modernas en hacer todo esto reproducible en lugar de artesanal.
Por qué el problema no es el SQL, es el tiempo
Piensa en un despliegue con dos o más réplicas de tu aplicación detrás de un balanceador. Durante un rolling deploy, mientras las réplicas se actualizan una a una, la Versión A (antigua) y la Versión B (nueva) del código atienden tráfico simultáneamente contra la misma base de datos, durante segundos o minutos. Si tu migración de esquema y tu despliegue de código asumen que ese solapamiento no existe, estás asumiendo un mundo que no es el que tienes en producción.
sequenceDiagram participant LB as Balanceador participant A as App v1 (antigua) participant B as App v2 (nueva) participant DB as Base de datos LB->>A: tráfico LB->>B: tráfico (rolling deploy en curso) A->>DB: espera columna antigua B->>DB: espera columna nueva Note over DB: Ambas versiones conviven<br/>durante el despliegue
Renombrar una columna en un solo paso es el ejemplo canónico de cómo esto sale mal: si el ALTER TABLE ... RENAME COLUMN se ejecuta antes de que todas las réplicas corran el código nuevo, las réplicas viejas empiezan a fallar inmediatamente porque la columna que esperan ya no existe. No importa que el SQL sea correcto; el problema es de secuencia temporal entre dos sistemas que se despliegan de forma independiente.
Migraciones aditivas vs destructivas
La primera distinción que hay que interiorizar es esta:
- Aditivas: añaden estructura sin tocar la existente.
ADD COLUMN(nullable o con un valor por defecto),CREATE TABLE,CREATE INDEX. El código antiguo, que no sabe nada de la columna nueva, sigue funcionando exactamente igual que antes: simplemente la ignora. - Destructivas: eliminan o cambian estructura que el código existente podría estar usando.
DROP COLUMN,RENAME COLUMN,ALTER COLUMN TYPE, añadir una restricciónNOT NULLsobre una columna que hasta ahora aceptaba nulos, o unRENAME TABLE.
La regla operativa que se deriva de esto es simple de enunciar y fácil de saltarse bajo presión: nunca cambies el esquema y el código que depende de él en el mismo despliegue. Cada cambio que rompería a la versión anterior del código se descompone en pasos aditivos, cada uno desplegable de forma independiente y segura incluso si el paso siguiente no llega a ejecutarse todavía.
El patrón expand-contract (parallel change)
El patrón expand-contract —también llamado parallel change— es la forma sistemática de aplicar esa regla a cualquier cambio de esquema que, hecho de golpe, sería destructivo. Se divide en tres fases:
- Expand (expandir): añade la nueva estructura sin tocar ni eliminar la antigua. Ambas coexisten.
- Migrate (migrar): mantén las dos estructuras sincronizadas mientras el código de la aplicación transiciona de una a otra, normalmente en varios despliegues.
- Contract (contraer): una vez que ningún código en producción depende ya de la estructura antigua, elimínala.
graph LR E["1. Expand<br/>Añadir columna nueva<br/>(sin tocar la antigua)"] --> M["2. Migrate<br/>Doble escritura +<br/>backfill de datos"] M --> S["Cambiar lecturas<br/>a la columna nueva"] S --> C["3. Contract<br/>Eliminar columna<br/>antigua"]
Ejemplo completo: renombrar una columna sin downtime
Supongamos que usuarios.nombre debe pasar a llamarse usuarios.nombre_completo, porque el modelo de datos ha evolucionado. Hecho en un solo ALTER TABLE ... RENAME COLUMN, esto rompe a cualquier réplica que siga corriendo la versión anterior del código durante el despliegue. Con expand-contract, son cuatro despliegues independientes, cada uno seguro por sí mismo:
-- Despliegue 1 (Expand): añadir la columna nueva, nullable
ALTER TABLE usuarios ADD COLUMN nombre_completo text;
-- Despliegue 2 (Migrate, parte 1): backfill de los datos existentes
-- Hazlo por lotes en tablas grandes, nunca en una sola transacción larga
UPDATE usuarios SET nombre_completo = nombre
WHERE nombre_completo IS NULL
AND id BETWEEN 1 AND 100000;
-- repetir por rangos hasta cubrir toda la tabla
En paralelo al backfill, el código de la aplicación pasa a escribir en ambas columnas (doble escritura) durante esta fase, para que ninguna fila nueva quede desincronizada mientras el backfill avanza sobre las filas antiguas.
-- Despliegue 3 (Migrate, parte 2): una vez todo el código en producción
-- escribe y lee de nombre_completo, puedes añadir la restricción
ALTER TABLE usuarios ALTER COLUMN nombre_completo SET NOT NULL;
-- Despliegue 4 (Contract): eliminar la columna antigua, solo cuando
-- ningún código en producción (ni analítica, ni jobs batch) la lea ya
ALTER TABLE usuarios DROP COLUMN nombre;
Cuatro despliegues en lugar de uno, sí. Pero cada uno es reversible sin pérdida de datos, cada uno es seguro incluso si el siguiente se retrasa semanas, y en ningún momento existe una versión del código que dependa de una estructura que no está ahí todavía. Esa es la compra: cambias velocidad de “terminar ya” por seguridad de “nunca hay una ventana rota”.
Trampas concretas en PostgreSQL
Más allá del patrón general, hay comportamientos específicos del motor que determinan si una migración “aditiva sobre el papel” en realidad bloquea la tabla en producción.
Índices: usa siempre CONCURRENTLY
CREATE INDEX toma por defecto un bloqueo que impide escrituras (aunque no lecturas) sobre la tabla mientras se construye. En una tabla grande y con tráfico de escritura constante, eso puede significar minutos de escrituras bloqueadas. La opción CONCURRENTLY evita ese bloqueo exclusivo a cambio de un build más lento (hace dos pasadas sobre la tabla y espera a que terminen las transacciones que puedan afectar al índice):
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);
CONCURRENTLY no puede ejecutarse dentro de una transacción, así que si tu herramienta de migraciones envuelve cada migración en una transacción por defecto (muchas lo hacen), tendrás que desactivar ese comportamiento explícitamente para esa migración concreta. Y si el build falla a mitad de camino —por una desconexión, una cancelación, una violación de unicidad—, el índice no desaparece: queda marcado como INVALID en el catálogo, sigue ocupando espacio y penalizando escrituras, y hay que hacer DROP INDEX explícito antes de reintentar.
NOT NULL con valor por defecto: cuidado con la reescritura de tabla
Añadir una columna NOT NULL con un valor por defecto constante es rápido en versiones modernas de PostgreSQL (desde la 11) porque el motor no reescribe físicamente cada fila: guarda el valor por defecto en el catálogo y lo aplica de forma perezosa a las filas antiguas cuando se leen. Pero endurecer una restricción NOT NULL sobre una columna ya existente y ya poblada sí requiere verificar cada fila:
-- Costoso en una tabla grande: escanea toda la tabla para validar
ALTER TABLE usuarios ALTER COLUMN email SET NOT NULL;
-- Alternativa que evita el bloqueo largo: valida primero como CHECK NOT VALID,
-- luego valida sin bloquear en un paso aparte
ALTER TABLE usuarios ADD CONSTRAINT usuarios_email_not_null
CHECK (email IS NOT NULL) NOT VALID;
ALTER TABLE usuarios VALIDATE CONSTRAINT usuarios_email_not_null;
El primer ALTER TABLE ... ADD CONSTRAINT ... NOT VALID es instantáneo (solo aplica a filas futuras desde ese momento); el VALIDATE CONSTRAINT posterior escanea las filas existentes pero solo toma un bloqueo ligero que permite lecturas y escrituras concurrentes, a diferencia de SET NOT NULL directo sobre una tabla grande.
Herramientas modernas de migración tipadas
La disciplina de expand-contract se puede aplicar con SQL a mano, pero en 2026 la mayoría de equipos en el ecosistema TypeScript/Node.js se apoyan en herramientas que generan y versionan migraciones a partir de un esquema declarado en código:
- Prisma Migrate: a partir de un esquema declarativo en
.prisma, genera automáticamente los archivos de migración SQL usando una base de datos “sombra” para calcular el diff, guarda el historial de migraciones aplicadas en una tabla propia, y avisa explícitamente cuando detecta que una migración generada sería destructiva (pérdida de datos). Es la opción más guiada y con más barreras de seguridad de fábrica. - Drizzle Kit: genera SQL de migración a partir de un esquema TypeScript, pero el flujo es distinto en filosofía: tú revisas (y si hace falta editas) el SQL generado antes de aplicarlo. Drizzle no pretende ser la autoridad final sobre la migración, sino un acelerador; tú sigues siendo responsable del SQL que se ejecuta.
- Atlas: toma un enfoque declarativo inspirado en Terraform —defines el estado deseado del esquema (en SQL, HCL o desde el esquema de tu ORM) y Atlas calcula el plan de migración necesario para llegar a ese estado—. Su valor diferencial es un conjunto de analizadores que se integran en CI/CD y detectan automáticamente cambios destructivos, bloqueos de tabla, o restricciones
NOT NULLsin valor por defecto antes de que la migración llegue a producción, no después.
Para proyectos con varios lenguajes o backends que no viven en el ecosistema Node, herramientas más antiguas como Flyway, Liquibase o golang-migrate cubren el mismo terreno (SQL versionado, aplicado de forma ordenada e idempotente) sin atarte a un ORM concreto. La elección entre unas y otras depende más del ecosistema en el que ya vive tu proyecto que de una diferencia fundamental de enfoque; lo que no cambia entre herramientas es la necesidad de aplicar expand-contract a cualquier cambio potencialmente destructivo.
Una checklist antes de fusionar una migración
- ¿Este cambio rompería el código que está en producción ahora mismo si se ejecutara solo? Si la respuesta es sí, es un candidato a expand-contract, no a un solo paso.
- ¿Toma un bloqueo exclusivo prolongado sobre una tabla con tráfico? Si creas un índice, usa
CONCURRENTLY. Si endureces una restricción, usaNOT VALID+VALIDATE CONSTRAINT. - ¿El backfill de datos se ejecuta por lotes, con confirmaciones frecuentes, en lugar de una transacción monolítica? En tablas de millones de filas, esto no es opcional.
- ¿Hay un plan de rollback para cada fase, no solo para la migración completa? Cada paso de expand-contract debe poder revertirse de forma independiente.
- ¿Alguien (o algo, como Atlas en CI) ha revisado el SQL generado buscando cambios destructivos antes de que llegue a producción? No confíes ciegamente en la migración autogenerada por tu ORM, especialmente en cambios de tipo de columna o restricciones nuevas.
- ¿Sabes qué otros consumidores leen esa tabla (jobs batch, otro microservicio, un pipeline de analítica) además de la aplicación que estás desplegando? La fase de contract solo es segura cuando ninguno de ellos depende ya de la estructura antigua.
Migrar sin miedo no significa migrar sin cuidado: significa haber descompuesto el cambio en pasos donde cada uno, por separado, no puede causar una incidencia, y tener la disciplina de no saltarse esa descomposición cuando la presión por terminar rápido invita a hacerlo todo de una vez.
Artículos relacionados
Monorepos full-stack en 2026: Turborepo, Nx y cuándo merece la pena
Un monorepo no es una arquitectura ni una moda: es una forma de organizar código que solo compensa a partir de ciertas señales. Comparamos Turborepo y Nx sin dogmatismo.
Bun.sql y los nuevos drivers nativos: bases de datos sin ORM pesado
Bun integra en el propio runtime clientes nativos para Postgres, MySQL y SQLite, sin instalar nada. Analizamos qué ofrece Bun.sql frente a un ORM tradicional y en qué casos conviene quedarse solo con SQL.
PostgreSQL en 2026: por qué sigue siendo la base de datos por defecto
Extensiones, rendimiento y un ecosistema cloud (Neon, Supabase, RDS) que ha convertido a PostgreSQL en la opción segura para casi cualquier proyecto nuevo en 2026.