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=trueon 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
Jobor an init step in the deployment pipeline runsflyway 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 onmigrate-at-start=trueracing 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.