Database Migrations

This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against.

Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change.

This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Quarkus wraps both major JVM migration tools — Flyway and Liquibase — as first-class extensions that run their migrations as part of application startup. This page covers the Quarkus-specific side of each: extension setup, the configuration properties that control when and how migrations run, callbacks, and multiple-datasource support. The migration tooling and strategy concepts themselves — versioned versus repeatable migrations, the forward-only philosophy, and how to choose between Flyway, Liquibase, Mongock and Flamingock in the first place — are covered in depth on Evolving the Database Model and are not repeated here.

Flyway

Extension setup

./mvnw quarkus:add-extension -Dextensions=flyway,jdbc-postgresql

which adds:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-flyway</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>

quarkus-flyway needs a JDBC driver extension for whichever database it migrates — quarkus-jdbc-postgresql above, or quarkus-jdbc-mysql, quarkus-jdbc-mariadb, quarkus-jdbc-mssql, quarkus-jdbc-oracle, and so on — since Flyway connects to the database directly to read and update its own history table.

Baseline

An existing database that was not created by Flyway (a legacy schema, or one hand-created before Flyway was introduced) needs to be baselined before Flyway will manage it, since Flyway otherwise refuses to run migrations against a schema it did not create and has no history for:

quarkus.flyway.baseline-on-migrate=true
quarkus.flyway.baseline-version=1.0.0
quarkus.flyway.baseline-description=Baseline of the pre-existing schema

quarkus.flyway.baseline-on-migrate=true tells Flyway to insert a synthetic "baseline" row into its history table — instead of failing — the first time it runs against a non-empty schema with no history table yet. quarkus.flyway.baseline-version sets the version that baseline row is recorded as; every migration script versioned at or below it is treated as already applied, and only scripts versioned above it actually run. This is a one-time bootstrapping step for an existing database, not something a project normally leaves permanently enabled once every environment has been baselined.

Migration locations

By default Flyway looks for migration scripts on the classpath under db/migration, following the naming convention V<version><description>.sql (e.g. src/main/resources/db/migration/V1.0.0create_book_table.sql). quarkus.flyway.locations overrides where it looks, and accepts several locations at once, mixing classpath and filesystem paths:

# classpath default, made explicit
quarkus.flyway.locations=classpath:db/migration

# a custom classpath package instead of the default
quarkus.flyway.locations=classpath:db/custom-migrations

# a filesystem path, useful when migrations are managed outside the packaged artifact
quarkus.flyway.locations=filesystem:/opt/app/migrations

# multiple locations, scanned together
quarkus.flyway.locations=classpath:db/migration,filesystem:/opt/app/migrations
-- src/main/resources/db/migration/V1.0.0__create_book_table.sql
CREATE TABLE book (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    isbn VARCHAR(20) NOT NULL UNIQUE
);

Migrate-at-start

quarkus.flyway.migrate-at-start=true

With migrate-at-start=true, Flyway runs every pending migration automatically the moment the application starts up, before it begins serving traffic. This is convenient for local development and for a single-instance deployment, but many teams deliberately turn it off (it defaults to false) in staging and production and instead run the migration as an explicit, separate step in the deployment pipeline:

  • Deployment-pipeline control — a migration becomes its own auditable pipeline stage, with its own approval gate and its own log output, rather than being buried inside application startup logs.

  • Avoiding N replicas racing to migrate — migrate-at-start=true on a service that scales to several replicas means every replica attempts to run pending migrations concurrently at boot. Flyway’s advisory lock on its history table makes this safe (only one replica actually applies each migration, the rest wait and then find nothing left to do), but it is still wasted work and an extra source of startup-time contention that a single, explicit migration step avoids entirely.

Callbacks

A Flyway Java callback hooks into specific points of the migration lifecycle (before/after the whole migrate operation, before/after each individual migration, and so on) by implementing org.flywaydb.core.api.callback.Callback as a CDI bean — Quarkus discovers and registers it automatically:

package com.example.migration;

import jakarta.enterprise.context.ApplicationScoped;

import org.flywaydb.core.api.callback.BaseCallback;
import org.flywaydb.core.api.callback.Context;
import org.flywaydb.core.api.callback.Event;

@ApplicationScoped
public class MigrationAuditCallback extends BaseCallback {

    @Override
    public boolean supports(Event event, Context context) {
        return event == Event.AFTER_MIGRATE;
    }

    @Override
    public void handle(Event event, Context context) {
        System.out.println("Flyway migration completed for " + context.getConfiguration().getDataSource());
    }
}

BaseCallback supplies no-op defaults for every lifecycle method, so a callback only needs to override supports and handle for the events it actually cares about. Flyway also supports purely SQL-based callbacks that need no Java code at all, triggered by file-naming convention alongside the regular migration scripts (e.g. afterMigrate.sql runs once after every successful migrate call):

-- src/main/resources/db/migration/afterMigrate.sql
INSERT INTO migration_audit (event, executed_at) VALUES ('AFTER_MIGRATE', now());

Multiple datasources

A project with more than one datasource configures Flyway per named datasource, mirroring the named-datasource configuration syntax covered on Datasources and Transactions:

quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/app

quarkus.datasource.reporting.db-kind=postgresql
quarkus.datasource.reporting.jdbc.url=jdbc:postgresql://localhost:5432/reporting

quarkus.flyway.locations=classpath:db/migration
quarkus.flyway.migrate-at-start=true

quarkus.flyway."reporting".locations=classpath:db/migration-reporting
quarkus.flyway."reporting".migrate-at-start=true

The unqualified quarkus.flyway. properties apply to the default datasource; quarkus.flyway."reporting". (the datasource name in quotes, matching the quarkus.datasource."reporting".* block it migrates) configures a completely independent Flyway instance — its own locations, its own baseline settings, its own migrate-at-start flag — for the reporting datasource.

Running it programmatically

Injecting org.flywaydb.core.Flyway directly, instead of relying on migrate-at-start, gives full control over when and how migration runs — for example from an application startup event, or from an operator-only admin endpoint that triggers migration on demand:

package com.example.migration;

import io.quarkus.flyway.FlywayDataSource;
import io.quarkus.runtime.StartupEvent;

import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;

import org.flywaydb.core.Flyway;

public class ExplicitMigrationRunner {

    @Inject
    Flyway flyway;

    @Inject
    @FlywayDataSource("reporting")
    Flyway reportingFlyway;

    void onStart(@Observes StartupEvent event) {
        flyway.migrate();
        reportingFlyway.migrate();
    }
}
package com.example.migration;

import io.quarkus.flyway.FlywayDataSource;

import jakarta.inject.Inject;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;

import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.output.MigrateResult;

@Path("/admin/migrations")
public class MigrationAdminResource {

    @Inject
    Flyway flyway;

    @Inject
    @FlywayDataSource("reporting")
    Flyway reportingFlyway;

    @POST
    @Path("/run")
    public Response run() {
        MigrateResult result = flyway.migrate();
        reportingFlyway.migrate();
        return Response.ok(result).build();
    }
}

The unqualified Flyway bean targets the default datasource; @io.quarkus.flyway.FlywayDataSource("reporting") selects the Flyway instance wired to the reporting datasource, the same way @ReactiveDataSource/ @DataSource qualifiers select a named AgroalDataSource elsewhere. Calling .migrate() explicitly like this is what a deployment pipeline’s separate migration step (see database.generation, Dev Services, and rolling deploys below) typically invokes, with quarkus.flyway.migrate-at-start left false.

Liquibase

Extension setup

./mvnw quarkus:add-extension -Dextensions=liquibase,jdbc-postgresql

which adds:

<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-liquibase</artifactId>
</dependency>
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>

Changelogs

quarkus.liquibase.change-log points at the master changelog — conventionally under src/main/resources/db/changeLog, in any of Liquibase’s supported formats (XML, YAML, or SQL):

quarkus.liquibase.change-log=db/changeLog/db.changelog-master.xml
<!-- src/main/resources/db/changeLog/db.changelog-master.xml -->
<databaseChangeLog
        xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
            http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.20.xsd">

    <changeSet id="1" author="alice">
        <createTable tableName="book">
            <column name="id" type="BIGINT" autoIncrement="true">
                <constraints primaryKey="true"/>
            </column>
            <column name="title" type="VARCHAR(200)">
                <constraints nullable="false"/>
            </column>
            <column name="isbn" type="VARCHAR(20)">
                <constraints nullable="false" unique="true"/>
            </column>
        </createTable>
    </changeSet>
</databaseChangeLog>

Migrate-at-start

quarkus.liquibase.migrate-at-start=true

Exactly as with Flyway’s migrate-at-start, this applies every pending changeset automatically on application startup; the same deployment-pipeline and multi-replica-racing considerations discussed under Flyway’s Migrate-at-start section apply equally here.

Labels and contexts

quarkus.liquibase.contexts and quarkus.liquibase.labels scope which changesets actually run in a given environment, matching them against a changeset’s own context or labels attribute:

# only run changesets whose context includes "prod" (or that declare no context at all)
quarkus.liquibase.contexts=prod

# only run changesets labelled for this environment's data-seeding needs
quarkus.liquibase.labels=seed-demo-data
<changeSet id="2" author="alice" context="prod">
    <createIndex indexName="idx_book_isbn" tableName="book">
        <column name="isbn"/>
    </createIndex>
</changeSet>

<changeSet id="3" author="alice" labels="seed-demo-data">
    <insert tableName="book">
        <column name="title" value="Demo Book"/>
        <column name="isbn" value="0000000000000"/>
    </insert>
</changeSet>

A changeset with no context/labels attribute always runs; one that declares either only runs when the running Liquibase instance’s configured contexts/labels match. Changelog and changeset authoring itself — the full element vocabulary, preconditions, rollback blocks — is covered on Liquibase: Getting Started and is not re-explained here.

Liquibase MongoDB

quarkus-liquibase-mongodb is the MongoDB equivalent of quarkus-liquibase, applying the same changelog-driven model to collection and index migrations instead of relational DDL:

./mvnw quarkus:add-extension -Dextensions=liquibase-mongodb
quarkus.liquibase-mongodb.change-log=db/changeLog/mongo.changelog-master.xml
quarkus.liquibase-mongodb.migrate-at-start=true

It shares the same change-log, migrate-at-start, contexts, and labels properties as quarkus-liquibase, under the quarkus.liquibase-mongodb.* prefix instead, and uses Liquibase’s MongoDB-specific change types (createCollection, createIndex, and so on) in place of relational DDL change types.

Migrations, database.generation, Dev Services, and rolling deploys

One schema-management mechanism per environment

Hibernate ORM’s own schema-generation setting, quarkus.hibernate-orm.database.generation, and a migration tool both know how to change the schema — but only one of them should be doing it in any environment that matters. The house rule: wherever Flyway or Liquibase owns the schema, database.generation must be validate (confirm the schema matches the entity model and fail fast if it does not) or none (do nothing), never update:

# Flyway/Liquibase owns the schema here -- Hibernate only checks it matches
%prod.quarkus.hibernate-orm.database.generation=validate
%prod.quarkus.flyway.migrate-at-start=false

Letting database.generation=update run alongside a migration tool means two independent, uncoordinated mechanisms can both alter the same schema — Hibernate’s own automatic DDL has no history table, no audit trail, and no coordination with Flyway’s or Liquibase’s, so the two can easily drift apart or race each other into a schema the migration tool’s own history no longer accurately describes.

Dev Services

A Dev-Services-provisioned database container (covered on the Hibernate ORM page) starts completely empty — no tables, no history. Two ways to get a usable schema into it during quarkus:dev:

# Option 1: let the migration tool populate the Dev Services container itself
%dev.quarkus.flyway.migrate-at-start=true

# Option 2: schema-less local dev when migrations aren't being exercised at all
%dev.quarkus.hibernate-orm.database.generation=drop-and-create
%dev.quarkus.flyway.migrate-at-start=false

migrate-at-start=true (or an explicit dev-mode migration call) runs the real migrations against the ephemeral container, which is the only way dev mode actually exercises the same migration scripts that will run in every other environment. database.generation=drop-and-create is the schema-less alternative: Hibernate derives and recreates the schema straight from the entity model on every restart, which is convenient while iterating on entities but does not exercise the migration scripts at all and should not be treated as a substitute for testing them.

Rolling deployments

A rolling Kubernetes deployment runs old-version and new-version pods against the same database at the same time for the duration of the rollout: with N old-version replicas still live and M new-version replicas already up, both sets of application code query and write the same schema simultaneously until the rollout finishes. A migration that the new version needs but that the old version cannot tolerate — dropping a column the old code still selects, renaming a table the old code still queries by its old name — breaks every old-version pod the moment it runs, not just once the rollout completes.

The standard mitigations:

  • Migrations must be backward-compatible with the previous application version for the whole rollout window — split what would be a single breaking change into an additive migration released alongside (or before) the new code, and a destructive migration (dropping the now-unused column/table) only in a later release, once no pod running the old version remains. This is the expand/contract pattern discussed in detail on Evolving the Database Model.

  • Run migrations as a separate step before the new ReplicaSet is scaled up — a Kubernetes Job or an init step in the deployment pipeline runs flyway migrate (or the Liquibase equivalent) to completion first, and only then does the rollout proceed to replace old pods with new ones. This avoids relying on migrate-at-start=true racing across whichever mix of old- and new-version replicas happen to be starting up at once, and gives the migration step its own pass/fail signal that can gate the rest of the rollout.

General schema-evolution strategy — versioning schemes, the expand/contract pattern in full, and how Mongock and Flamingock apply the same ideas outside the relational world — is covered on Evolving the Database Model and is not repeated here.