Migración de base de datos con Flyway en Spring Boot

1. Descripción general

Cuando la aplicación cambia, el esquema de la base de datos también debe cambiar. Si ejecutas manualmente cada sentencia ALTER TABLE en cada entorno, eventualmente dev, staging y production se desincronizarán. Flyway resuelve este problema: cada cambio de esquema es un archivo de migración con versión, aplicado automáticamente y en el orden correcto.

En este artículo, configurarás Flyway para una aplicación Spring Boot 3, escribirás migraciones en SQL y Java, las probarás con JUnit 5 y aprenderás a manejar errores comunes como desajustes de checksum.

2. Instalar dependencias

Spring Boot ya gestiona la versión de Flyway, por lo que no necesitas especificar una versión. El ejemplo siguiente utiliza PostgreSQL:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-database-postgresql</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

A partir de Flyway 10 en adelante (incluido con Spring Boot 3.3+), la compatibilidad con cada tipo de base de datos se ha separado en módulos independientes. Si falta flyway-database-postgresql, la aplicación no se iniciará y reportará un error:

org.flywaydb.core.api.FlywayException: Unsupported Database: PostgreSQL 16.4

H2 todavía está incluido en flyway-core, por lo que las pruebas no necesitan módulos adicionales.

3. Configuración básica

Si solo tienes flyway-core en el classpath, Spring Boot creará automáticamente el bean Flyway y ejecutará la migración antes de que EntityManagerFactory se inicialice. La configuración mínima en application.properties:

spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=shop
spring.datasource.password=secret

spring.jpa.hibernate.ddl-auto=validate

spring.flyway.locations=classpath:db/migration
spring.flyway.validate-on-migrate=true

Las líneas importantes:

  • spring.jpa.hibernate.ddl-auto=validate: Hibernate solo verifica si las entidades coinciden con el esquema. No modifica el esquema automáticamente. Flyway es la única fuente de verdad para el esquema.
  • spring.flyway.locations: directorio que contiene las migraciones. El valor predeterminado es classpath:db/migration. Aquí se especifica explícitamente para mayor claridad.
  • spring.flyway.validate-on-migrate=true: antes de migrar, Flyway verifica el checksum de las migraciones ya ejecutadas. Este también es el valor predeterminado.

Nota: No uses ddl-auto=update o create junto con Flyway. Hibernate y Flyway modificarían el esquema simultáneamente, y el resultado sería impredecible.

4. Escribir migraciones en SQL

4.1. Convención de nombres

Flyway identifica las migraciones por el nombre del archivo:

V1__create_customer_table.sql
│ │  └── descripción (los guiones bajos se interpretan como espacios)
│ └── dos guiones bajos como separador
└── V = migración versionada, seguida del número de versión

La versión puede ser 1, 2, 1.1 o 20261016.1. Flyway compara versiones por valor numérico, no alfabéticamente. Por lo tanto, V10 se ejecuta después de V9.

4.2. Primera migración

Crea el archivo src/main/resources/db/migration/V1__create_customer_table.sql:

CREATE TABLE customer (
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    full_name  VARCHAR(100) NOT NULL,
    created_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Luego viene V2__create_orders_table.sql:

CREATE TABLE orders (
    id          BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    customer_id BIGINT         NOT NULL REFERENCES customer (id),
    total       NUMERIC(12, 2) NOT NULL,
    status      VARCHAR(20)    NOT NULL
);

CREATE INDEX idx_orders_customer_id ON orders (customer_id);

Al iniciar la aplicación, el registro mostrará algo como:

Flyway Community Edition 10.x by Redgate
Database: jdbc:postgresql://localhost:5432/shop (PostgreSQL 16.4)
Successfully validated 2 migrations (execution time 00:00.012s)
Creating Schema History table "public"."flyway_schema_history" ...
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version "1 - create customer table"
Migrating schema "public" to version "2 - create orders table"
Successfully applied 2 migrations to schema "public", now at version v2

4.3. Tabla flyway_schema_history

Flyway registra todas las migraciones ejecutadas en la tabla flyway_schema_history. Puedes consultarla directamente:

SELECT installed_rank, version, description, checksum, success
FROM flyway_schema_history
ORDER BY installed_rank;
 installed_rank | version |      description      |  checksum   | success
----------------+---------+-----------------------+-------------+---------
              1 | 1       | create customer table | -1253721480 | t
              2 | 2       | create orders table   |   873416502 | t

En el siguiente inicio, Flyway ve que el esquema está en la versión 2, por lo que no ejecuta nada más. La columna checksum es clave para la sección 7.

4.4. Migración repetible

Los archivos que comienzan con R__ son migraciones repetibles. No tienen versión. Flyway ejecuta nuevamente este archivo cada vez que su checksum cambia, y siempre se ejecuta después de todas las migraciones versionadas. Este tipo es útil para vistas, funciones o procedimientos almacenados:

-- R__order_summary_view.sql
CREATE OR REPLACE VIEW order_summary AS
SELECT c.id AS customer_id, c.full_name, COUNT(o.id) AS order_count
FROM customer c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.full_name;

5. Migraciones en Java

Algunos cambios son difíciles de escribir en SQL puro, como backfill de datos que requieren lógica de procesamiento de cadenas. En ese caso, escribes la migración en Java, extendiendo BaseJavaMigration.

Primero, añade la columna en SQL en V3__add_customer_email.sql:

ALTER TABLE customer ADD COLUMN email VARCHAR(150);

Luego coloca la siguiente clase en el paquete db.migration, en src/main/java/db/migration/V4__Backfill_customer_email.java:

package db.migration;

import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.SingleConnectionDataSource;

import java.text.Normalizer;

public class V4__Backfill_customer_email extends BaseJavaMigration {

    @Override
    public void migrate(Context context) {
        var jdbc = new JdbcTemplate(
                new SingleConnectionDataSource(context.getConnection(), true));

        jdbc.query("SELECT id, full_name FROM customer WHERE email IS NULL", rs -> {
            long id = rs.getLong("id");
            String email = toSlug(rs.getString("full_name")) + "." + id + "@example.com";
            jdbc.update("UPDATE customer SET email = ? WHERE id = ?", email, id);
        });
    }

    private static String toSlug(String name) {
        String noAccent = Normalizer.normalize(name, Normalizer.Form.NFD)
                .replaceAll("\\p{M}", "")
                .replace('đ', 'd').replace('Đ', 'D');
        return noAccent.toLowerCase().trim().replaceAll("\\s+", ".");
    }
}

Explicación:

  • El nombre de la clase sigue la misma convención que los archivos SQL: V4__Backfill_customer_email.
  • context.getConnection() devuelve la conexión que está usando Flyway, dentro de la misma transacción de la migración. El parámetro true en SingleConnectionDataSource evita que JdbcTemplate cierre esta conexión.
  • Flyway escanea el paquete db.migration porque corresponde a classpath:db/migration.

Con un cliente “Nguyễn Văn An” que tiene id 1, el resultado es nguyen.van.an.1@example.com.

Spring Boot también registra automáticamente cualquier bean de tipo JavaMigration en Flyway. Por lo tanto, puedes declarar la migración como @Component. Sin embargo, no deberías inyectar repositorios o servicios en migraciones. El código de negocio cambia con el tiempo, pero las migraciones deben ejecutarse idénticamente siempre.

6. Probar migraciones

Deberías tener al menos una prueba que confirme que todas las migraciones se ejecutan desde un esquema vacío y que Hibernate valida correctamente. Crea src/test/resources/application-test.properties:

spring.datasource.url=jdbc:h2:mem:shop;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=

Prueba:

import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ActiveProfiles("test")
class FlywayMigrationTest {

    @Autowired
    private Flyway flyway;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void whenContextStarts_thenAllMigrationsAreApplied() {
        var current = flyway.info().current();

        assertThat(current).isNotNull();
        assertThat(current.getVersion().getVersion()).isEqualTo("4");
        assertThat(flyway.info().pending()).isEmpty();
    }

    @Test
    void givenNewCustomer_whenInsert_thenEmailColumnExists() {
        jdbcTemplate.update(
                "INSERT INTO customer (full_name, email) VALUES (?, ?)",
                "Trần Thị Bình", "binh@example.com");

        String email = jdbcTemplate.queryForObject(
                "SELECT email FROM customer WHERE full_name = ?",
                String.class, "Trần Thị Bình");

        assertThat(email).isEqualTo("binh@example.com");
    }
}

La primera prueba verifica que el esquema está en la versión 4 y no hay migraciones pendientes. Si un archivo SQL tiene un error de sintaxis, el contexto no se iniciará y la prueba fallará inmediatamente. La segunda prueba confirma que la columna email de V3 realmente existe.

Nota: H2 en MODE=PostgreSQL solo simula parcialmente la sintaxis de PostgreSQL. Si las migraciones usan características específicas como JSONB, índices parciales o CREATE INDEX CONCURRENTLY, deberías ejecutar las pruebas en PostgreSQL real usando Testcontainers.

7. Errores comunes y cómo manejarlos

7.1. Modificar una migración ya ejecutada

Este es el error más común. Supongamos que modificas V2__create_orders_table.sql después de que se haya ejecutado en staging. El siguiente inicio reportará:

Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 2
-> Applied to database : 873416502
-> Resolved locally    : -402981177

La regla es: nunca modifiques una migración que se haya ejecutado en cualquier entorno compartido. Si necesitas hacer cambios, crea una nueva migración, por ejemplo V5__alter_orders_status_length.sql.

Si el cambio es solo un comentario o espacio en blanco y estás seguro de que el esquema no cambió, puedes actualizar el checksum con el comando repair. Por ejemplo, con el plugin de Flyway para Maven:

mvn flyway:repair -Dflyway.url=jdbc:postgresql://localhost:5432/shop \
  -Dflyway.user=shop -Dflyway.password=secret

repair también elimina los registros de migraciones fallidas de flyway_schema_history. Esto es importante con MySQL, ya que MySQL no puede hacer rollback de DDL dentro de una transacción.

7.2. Versión duplicada cuando trabajas en equipo

Si dos desarrolladores crean V5__... en diferentes ramas y luego se fusionan, encontrarás el error Found more than one migration with version 5. Hay dos formas de prevenirlo:

  • Usa versiones basadas en timestamp, por ejemplo V20261016_0930__add_order_note.sql.
  • Acuerda “reservar” versiones antes de crear archivos.

Hay otro caso: la rama A fusiona V7 primero, luego la rama B fusiona V6. Flyway reportará que V6 se ignora. Puedes habilitar spring.flyway.out-of-order=true, pero es mejor cambiar la versión del archivo nuevo a una superior a la versión actual.

7.3. Aplicar Flyway a una base de datos existente

Con una base de datos de producción que ya tiene tablas pero no tiene flyway_schema_history, Flyway se negará a ejecutarse porque el esquema no está vacío. Usa baseline:

spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1

Flyway marca el esquema actual como versión 1. Luego solo ejecuta migraciones con versión superior a 1. En este caso, V1 debería ser un volcado del esquema actual, para que los entornos nuevos aún puedan construirse desde cero.

Nota: Solo habilita baseline-on-migrate durante la migración inicial. Si lo dejas habilitado durante mucho tiempo y accidentalmente apuntas a una base de datos existente con tablas, Flyway hará baseline en lugar de reportar un error.

7.4. Comando clean

flyway clean elimina todos los objetos en el esquema. Spring Boot 3 ya establece spring.flyway.clean-disabled=true por defecto. No desactives esta opción en ningún perfil que pueda acceder a producción.

7.5. Migraciones que tardan mucho

Spring Boot ejecuta migraciones al iniciar. Un ALTER TABLE en una tabla con decenas de millones de filas puede bloquear la tabla y causar que el health check falle. En este caso, deberías:

  • Dividir el cambio en pasos: añadir columna nullable, hacer backfill por lotes, luego añadir NOT NULL.
  • Desactivar auto-migrate con spring.flyway.enabled=false y ejecutar Flyway como un paso separado en el pipeline de deploy, antes de desplegar la aplicación.

8. Cuándo no usar Flyway de la forma predeterminada

Flyway es muy adecuado para la mayoría de las aplicaciones Spring Boot. Sin embargo, la forma “migrar al iniciar” tiene limitaciones:

  • Ejecutar múltiples instancias simultáneamente: Flyway usa bloqueos en la base de datos, por lo que no ejecuta migraciones duplicadas. Sin embargo, las otras instancias deben esperar, y el tiempo de inicio aumenta.
  • La cuenta de la aplicación no tiene permisos DDL: este es un requisito de seguridad común en producción. En ese caso, ejecutas Flyway con una cuenta separada en el pipeline.
  • Necesitas rollback automático: la versión Community no tiene deshacer migraciones. Debes escribir migraciones “hacia adelante” para deshacer cambios.

9. Conclusión

En este artículo, integraste Flyway en Spring Boot 3. Escribiste migraciones versionadas, migraciones repetibles y migraciones en Java, las probaste con JUnit 5 y AssertJ, y aprendiste a manejar errores comunes como desajustes de checksum, versiones duplicadas o baseline de bases de datos antiguas.

Hay tres principios que deberías recordar:

  1. Flyway es el único lugar donde se modifica el esquema, mientras que Hibernate solo valida.
  2. Una migración que se ha ejecutado nunca se debe modificar.
  3. Siempre deberías tener una prueba que ejecute todas las migraciones desde un esquema vacío.
spring-bootflywaydatabasemigrationpostgresql