From SQL Scripts to a Migration Lifecycle
Building observable, policy-driven Flyway migrations with Quarkus callbacks
Most teams think of database migrations as a one-way operation. You apply schema changes, Flyway reports success, and the application starts. In development, this mental model mostly works. In production, it breaks down fast.
Real systems need more than schema changes. They need validation before migrations run, safety checks around destructive changes, visibility into how long migrations take, and clear signals when something goes wrong. Without this, migrations become blind spots. When they fail at startup, your service crashes before it ever exposes a health endpoint. At 2am, all you see is a container restarting in a loop.
Teams often try to solve this by stuffing logic into migration SQL files or running ad-hoc scripts before deployment. That approach does not scale. SQL cannot send notifications, cannot inspect application configuration, and cannot coordinate with the rest of your runtime. Worse, these scripts are invisible to Flyway’s lifecycle, so failures are hard to reason about.
Flyway callbacks solve this by turning migrations into a lifecycle with well-defined extension points. Used correctly, callbacks let you validate state, emit signals, and enforce invariants around migrations without polluting your schema history.
This tutorial shows how to use Flyway callbacks correctly in a Quarkus application, what they guarantee, and where their limits are.
Prerequisites
You will build a small Quarkus application that uses Flyway with both SQL and Java-based callbacks.
You need:
Java 21 installed
Quarkus CLI available
Basic understanding of SQL migrations
Familiarity with CDI and application startup
PostgreSQL available via Quarkus Dev Services
About 20 minutes
Project Setup
Create the project:
quarkus create app org.acme:flyway-callbacks \
--extension=quarkus-rest-jackson,quarkus-flyway,quarkus-jdbc-postgresql,quarkus-hibernate-orm-panache \
--no-code
cd flyway-callbacksJust want to look at a complete project? Make sure to check out my Github repository! Star, Fork and watch out for new projects!
This gives you:
quarkus-flywayfor migration supportquarkus-jdbc-postgresqlfor database accessquarkus-hibernate-orm-panacheto demonstrate CDI interaction from callbacksquarkus-rest-jacksonREST support.
Dev Services will start PostgreSQL automatically in dev and test modes. No container setup is required.
Additionally to your jdbc driver you also need to add the corresponding flyway dependency. For PostgreSQL this is:
<!-- Flyway PostgreSQL specific dependencies -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>While we are here, let’s also make sure we see what is going on and turn off Hibernates schema generation strategy and turn on logging:
Add the following to your resources/application.properties
quarkus.hibernate-orm.schema-management.strategy = none
quarkus.hibernate-orm.log.sql = true
quarkus.flyway.migrate-at-start=true
quarkus.flyway.baseline-on-migrate=trueWhat does this do?
migrate-at-start: Flyway runs migrations when the app boots up. No manual steps.baseline-on-migrate: If the database already has tables, it marks a starting point to avoid reapplying old migrations.baseline-on-migrateshould only be enabled for legacy databases. Never use it to “fix” broken migration history.
Baseline Migration
Before adding callbacks, create a simple schema.
Create src/main/resources/db/migration/V1__create_person_table.sql:
CREATE TABLE person (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
registered_at TIMESTAMP NOT NULL DEFAULT NOW()
);Adding a Minimal Entity
At this point, Flyway would run successfully, but the application has no runtime model. There is no entity, no ORM metadata, and no reason for Hibernate to initialize anything beyond wiring itself up.
That is fine for pure migration projects, but for a hands-on tutorial it is unsatisfying. We need a minimal entity so that:
Hibernate ORM initializes cleanly
The schema we migrated is actually used
Later callbacks that rely on JPA or Panache make sense
Create a simple entity that maps directly to the table created in the migration.
Create src/main/java/org/acme/model/Person.java:
package org.acme.model;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = "person")
public class Person extends PanacheEntity {
@Column(nullable = false)
public String name;
@Column(nullable = false, unique = true)
public String email;
}This entity does three important things:
It forces Hibernate ORM to initialize at startup
It validates that the migrated schema matches the application model
It enables later callbacks to interact with real domain data
Start the application:
quarkus devFlyway runs automatically at startup. At this point, migrations are purely structural. There is no validation, no observability, and no post-processing.
That is exactly the gap callbacks fill.
SQL Callbacks: Simple, Declarative Hooks
SQL callbacks are the lowest-friction way to extend Flyway. They run SQL at specific lifecycle events and are discovered automatically.
Post-Migration Data Seeding
Create src/main/resources/db/migration/afterMigrate.sql:
INSERT INTO person (name, email)
VALUES
('Test User', 'test@example.com'),
('Admin User', 'admin@example.com')
ON CONFLICT (email) DO NOTHING;This runs after all migrations complete successfully.
The guarantee is simple: if Flyway reports success, this SQL has executed. The limit is just as important: this file cannot branch on environment, cannot call external systems, and cannot see application configuration.
Pre-Migration Validation
Create src/main/resources/db/migration/beforeMigrate.sql:
CREATE TABLE IF NOT EXISTS migration_audit (
event TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
INSERT INTO migration_audit (event)
VALUES ('Migration starting');This runs before Flyway applies any versioned migration.
SQL callbacks are ideal for simple invariants and bookkeeping. They are not a replacement for application logic. Well, this is a little weak Markus. Pre-migration for logging?
What Pre-Migration Validation Is Actually For
A beforeMigrate callback exists for one reason:
To stop a migration early if the system is in a state where applying schema changes would be dangerous or invalid.
It is not for logging.
It is not for metrics.
It is not for seeding data.
Those belong after migration.
Pre-migration validation is about blocking startup when invariants are violated.
Real Things You Validate Before Migration
In production systems, teams typically use pre-migration validation to enforce constraints that Flyway itself cannot express.
Environment Safety Checks
You validate that migrations are running in the expected environment.
Example: refuse to run destructive migrations in production unless explicitly enabled.
-- beforeMigrate.sql
DO $$
BEGIN
IF current_setting('application_name', true) NOT LIKE '%prod%' THEN
RAISE NOTICE 'Non-production environment detected';
ELSE
IF current_setting('flyway.allow_destructive', true) IS DISTINCT FROM 'true' THEN
RAISE EXCEPTION 'Destructive migrations are disabled in production';
END IF;
END IF;
END $$;What this guarantees:
Production migrations cannot run accidentally without an explicit opt-in.
What it does not guarantee:
It does not analyze the migration scripts themselves. It only gates execution.
Required Baseline State
You validate that critical data or schemas already exist before migrating further.
Example: ensure a baseline migration ran years ago and was never deleted.
-- beforeMigrate.sql
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM flyway_schema_history
WHERE version = '1'
) THEN
RAISE EXCEPTION 'Baseline migration V1 is missing. Aborting.';
END IF;
END $$;This prevents:
Running migrations against a partially restored database
Accidentally migrating a clone missing historical context
Operational Preconditions
You validate conditions that are external to schema structure but still database-visible.
Examples:
Replication slots not lagging
Database not in read-only mode
No long-running transactions blocking DDL
-- beforeMigrate.sql
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_stat_activity
WHERE state = 'active'
AND now() - query_start > interval '10 minutes'
) THEN
RAISE EXCEPTION 'Long-running transactions detected. Aborting migration.';
END IF;
END $$;This prevents migrations from hanging or causing lock amplification.
Java Callbacks: Integrating with Quarkus
For anything beyond simple SQL, you need Java callbacks.
Flyway discovers callbacks via Java’s service loader. In Quarkus, this integrates cleanly with CDI. You do not manually register callbacks.
Callbacks are startup code, not application code
Flyway callbacks execute before your application becomes ready. If a callback blocks, fails, or hangs, your service never starts. Treat callbacks like bootstrap logic, not business logic.
Example Pre Migration Callback
Create src/main/java/org/acme/callbacks/PreMigrationValidationCallback.java:
package org.acme.callbacks;
import org.eclipse.microprofile.config.ConfigProvider;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.callback.Callback;
import org.flywaydb.core.api.callback.Context;
import org.flywaydb.core.api.callback.Event;
import io.quarkus.logging.Log;
public class PreMigrationValidationCallback implements Callback {
@Override
public boolean supports(Event event, Context context) {
return event == Event.BEFORE_MIGRATE;
}
@Override
public boolean canHandleInTransaction(Event event, Context context) {
return false;
}
@Override
public void handle(Event event, Context context) {
String profile = ConfigProvider.getConfig().getValue("quarkus.profile", String.class);
boolean destructiveAllowed = ConfigProvider.getConfig()
.getOptionalValue("migration.destructive.allowed", Boolean.class).orElse(false);
Log.infof("Profile: %s, Destructive allowed: %b", profile, destructiveAllowed);
if ("prod".equals(profile) && !destructiveAllowed) {
throw new FlywayException(
"Refusing to run migrations in production without explicit approval");
}
}
@Override
public String getCallbackName() {
return "pre-migration-validation";
}
}This callback runs outside any Flyway transaction. That matters. Notifications, metrics, and external calls must never participate in schema transactions.
You need to configure callbacks in application.properties:
quarkus.flyway.callbacks = org.acme.callbacks.PreMigrationValidationCallback.class
migration.destructive.allowed = falseWatch the logs for the output:
INFO [org.acme.callbacks.PreMigrationValidationCallback] (Quarkus Main Thread) Profile: dev, Destructive allowed: falseMigration Performance Monitoring
Callbacks can measure migration duration without interfering with Flyway.
package org.acme.callbacks;
import org.flywaydb.core.api.callback.Callback;
import org.flywaydb.core.api.callback.Context;
import org.flywaydb.core.api.callback.Event;
import io.quarkus.logging.Log;
public class MigrationTimingCallback implements Callback {
private static final ThreadLocal<Long> START_TIME = new ThreadLocal<>();
@Override
public boolean supports(Event event, Context context) {
return event == Event.BEFORE_MIGRATE || event == Event.AFTER_MIGRATE;
}
@Override
public boolean canHandleInTransaction(Event event, Context context) {
return false;
}
@Override
public void handle(Event event, Context context) {
if (event == Event.BEFORE_MIGRATE) {
START_TIME.set(System.currentTimeMillis());
}
if (event == Event.AFTER_MIGRATE) {
Long startTime = START_TIME.get();
if (startTime != null) {
long duration = System.currentTimeMillis() - startTime;
Log.infof("Flyway migrations completed in %d ms", duration);
START_TIME.remove();
}
}
}
@Override
public String getCallbackName() {
return "migration-timing-callback";
}
public MigrationTimingCallback() {
}
}MigrationTimingCallback remains stateless, as required by Flyway, by replacing the instance field with a ThreadLocal<Long>. Since Flyway executes migrations on a single thread per data source, we store the start time in the ThreadLocal during the BEFORE_MIGRATE event. This value is then retrieved and removed during the corresponding AFTER_MIGRATE event to calculate the duration, allowing us to track state across the lifecycle events without modifying the callback instance itself or risking thread-safety issues.
Add the callback to the application.properties:
quarkus.flyway.callbacks = org.acme.callbacks.PreMigrationValidationCallback,org.acme.callbacks.MigrationTimingCallbackAnd watch the logs:
INFO [org.acme.callbacks.MigrationTimingCallback] (Quarkus Main Thread) Flyway migrations completed in 10 msFlyway does not guarantee callback ordering within the same event. Callbacks must not depend on each other’s side effects.
Accessing Migration Context
The Context object provides rich information:
package org.acme.callbacks;
import org.flywaydb.core.api.MigrationInfo;
import org.flywaydb.core.api.callback.Callback;
import org.flywaydb.core.api.callback.Context;
import org.flywaydb.core.api.callback.Event;
import io.quarkus.logging.Log;
public class MigrationContextCallback implements Callback {
@Override
public boolean supports(Event event, Context context) {
return event == Event.BEFORE_EACH_MIGRATE;
}
@Override
public boolean canHandleInTransaction(Event event, Context context) {
return false;
}
@Override
public void handle(Event event, Context context) {
// Get the current migration info
MigrationInfo info = context.getMigrationInfo();
if (info != null) {
Log.infof("Running migration: %s", info.getDescription());
Log.infof("Version: %s", info.getVersion());
Log.infof("Script: %s", info.getScript());
}
// Access the database connection
// Connection connection = context.getConnection();
// Get Flyway configuration
// Configuration config = context.getConfiguration();
}
@Override
public String getCallbackName() {
return "migration-context-callback";
}
public MigrationContextCallback() {
}
}Add to application properties:
quarkus.flyway.callbacks = org.acme.callbacks.PreMigrationValidationCallback,org.acme.callbacks.MigrationTimingCallback,org.acme.callbacks.MigrationContextCallbackand watch the logs:
INFO [org.acme.callbacks.MigrationContextCallback] (Quarkus Main Thread) MigrationContextCallback Called
INFO [org.acme.callbacks.MigrationContextCallback] (Quarkus Main Thread) Running migration: create person table
INFO [org.acme.callbacks.MigrationContextCallback] (Quarkus Main Thread) Version: 1
INFO [org.acme.callbacks.MigrationContextCallback] (Quarkus Main Thread) Script: db/migration/V1__create_person_table.sqlNote that the info about the migration being handled is only available for the BEFORE_EACH_* and AFTER_EACH_* events. Null in all other cases.
Production Hardening
Transaction Boundaries
Callbacks do not participate in schema transactions unless explicitly allowed. This is a feature, not a bug. Notifications, backups, and metrics must not affect migration atomicity.
Failure Modes
If a callback fails after migration, the application fails to start. Your schema is already updated. Design callbacks to be idempotent and fast.
Security
Callbacks execute with full database access. Treat them as production code. No dynamic SQL. No unbounded queries. No network calls without timeouts.
Conclusion
Flyway callbacks turn migrations into a lifecycle instead of a black box. Used well, they add safety, observability, and control without polluting your schema history. Used poorly, they create hidden startup failures.
Keep callbacks small, explicit, and boring.
The complete code belongs in your repository alongside your migrations, not in your deployment scripts.


