Imagine the following scenario: PostgreSQL accepts ALTER TABLE customer RENAME COLUMN full_name TO display_name. Flyway records a successful migration. An old application instance can still run SELECT full_name FROM customer a moment later. That query fails with SQLSTATE 42703 because the column disappeared during the rollout.
This happens during normal deployments. Kubernetes replaces instances over time, so old and new code run together. Blue-green deployments keep both versions alive by design. A rollback starts the old code again. All of them use the same database, and the database does not change version with the application process.
Flyway orders schema changes and records which ones ran. It cannot inspect the SQL inside every running application version. We have to design that compatibility into the release. Otherwise the migration succeeds and the next request fails.
My earlier guides cover the basic Quarkus and Flyway setup and Flyway callbacks for production checks. This time we change a live schema while two application versions use it. Follow along for the ride if you like:
What We Build
We build a small customer API with Quarkus 3.37.2, Java 21, Flyway, and PostgreSQL 18.4. We rename customer.full_name to display_name in four schema stages:
Create the original table
Expand it with
display_nameand a temporary compatibility triggerBackfill existing rows and require the new column
Remove the trigger and old column after the rollback window closes
The demo binary has three release modes. This lets us run old and new SQL against one database. LEGACY only knows full_name. BRIDGE reads and writes both columns. MODERN only knows display_name.
A real service would ship these changes in separate application versions. The three modes help to keep this demo easy to run because you do not need three Git checkouts and I don’t have to tweak my lovely mono-repository.
What You Need
Quarkus Dev Services starts PostgreSQL for the tests. For the manual run, two application processes share one PostgreSQL container in Podman.
JDK 21 installed
Quarkus CLI 3.37.x
Podman 5 or later
curlAbout two ☕️
On macOS or Windows, start the Podman machine before using Dev Services:
podman machine startCreate the Project
Create the application or start from the ready build out project on my Github repository:
quarkus create app com.themainthread.flyway:flyway-zero-downtime \
--platform-bom=io.quarkus.platform:quarkus-bom:3.37.2 \
--java=21 \
--extensions=rest-jackson,jdbc-postgresql,flyway,hibernate-validator \
--no-code \
--no-dockerfiles
cd flyway-zero-downtimeThe project uses these extensions:
quarkus-rest-jacksonfor the customer JSON APIquarkus-jdbc-postgresqlfor the JDBC driver and Agroal connection poolquarkus-flywayfor migration and history validationquarkus-hibernate-validatorfor request validation
Flyway loads PostgreSQL support from a database-specific module. The HTTP tests use RestAssured. At some point someone needs to explain to me why I do not get RestAssured automatically added when I use rest dependencies.
For now we do it manually and add both dependencies to the generated pom.xml:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>The Quarkus platform manages the dependency versions.
Start With the Legacy Schema
Create src/main/resources/db/migration/V1__create_customer_table.sql:
CREATE TABLE customer (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL
);The first application release inserts, reads, and updates full_name. A direct rename is a one liner, so it is an easy V2 to write:
ALTER TABLE customer RENAME COLUMN full_name TO display_name;PostgreSQL updates the catalog and does not keep an alias for old SQL. The legacy query now fails:
ERROR: column "full_name" does not exist
SQL state: 42703The migration succeeded. Flyway does not know that an old application instance still sends this query. The database change is part of the application release, so every running version must support the schema at that point in the rollout.
We keep the direct rename in src/test/resources/db/naive/ so a test can produce the failure. The compatible migration history goes into src/main/resources/db/migration/.
Expand the Schema First
With expand-contract, we add the new schema first and keep the old schema working. Then we move application traffic and data. A later release removes the old schema. During the middle stage, both application versions work against the same database.
Create src/main/resources/db/migration/V2__expand_with_display_name.sql:
SET LOCAL lock_timeout = '5s';
ALTER TABLE customer ADD COLUMN display_name TEXT;
CREATE FUNCTION sync_customer_name_columns()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.full_name IS NULL THEN
NEW.full_name := NEW.display_name;
END IF;
IF NEW.display_name IS NULL THEN
NEW.display_name := NEW.full_name;
END IF;
ELSIF NEW.full_name IS DISTINCT FROM OLD.full_name THEN
NEW.display_name := NEW.full_name;
ELSIF NEW.display_name IS DISTINCT FROM OLD.display_name THEN
NEW.full_name := NEW.display_name;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER customer_name_compatibility
BEFORE INSERT OR UPDATE OF full_name, display_name ON customer
FOR EACH ROW
EXECUTE FUNCTION sync_customer_name_columns();The nullable display_name column does not break old code. The trigger handles writes from instances that do not know this column yet. A legacy insert supplies full_name, and the trigger copies the value to display_name. A modern insert can supply only display_name; the trigger fills the old NOT NULL column during the transition.
The trigger also covers updates. Assume the bridge release wrote both values and an old instance later changed only full_name. A bridge query with COALESCE(display_name, full_name) would return the stale display_name. The IS DISTINCT FROM checks find which column changed and copy that value and also handle NULL correctly.
The trigger is kind of temporary migration code. It runs on every name update, and developers now have to remember that two columns represent one field. We keep it during the compatibility window, test it, and remove it with the old column.
Make the Release Mode Explicit
We need Flyway to stop at V2 and V3 while we run the matching application modes. Quarkus does not expose Flyway’s target option as a standard configuration property, so we set it through FlywayConfigurationCustomizer.
Create src/main/java/com/themainthread/flyway/config/MigrationDemoConfig.java:
package com.themainthread.flyway.config;
import java.util.Optional;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
@ConfigMapping(prefix = "migration-demo")
public interface MigrationDemoConfig {
@WithDefault("MODERN")
Release release();
Optional<String> schemaTarget();
enum Release {
LEGACY,
BRIDGE,
MODERN
}
}Create src/main/java/com/themainthread/flyway/config/MigrationTargetCustomizer.java:
package com.themainthread.flyway.config;
import org.flywaydb.core.api.MigrationVersion;
import org.flywaydb.core.api.configuration.FluentConfiguration;
import io.quarkus.flyway.FlywayConfigurationCustomizer;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
@Singleton
public class MigrationTargetCustomizer implements FlywayConfigurationCustomizer {
private final MigrationDemoConfig config;
@Inject
public MigrationTargetCustomizer(MigrationDemoConfig config) {
this.config = config;
}
@Override
public void customize(FluentConfiguration configuration) {
config.schemaTarget()
.map(MigrationVersion::fromVersion)
.ifPresent(configuration::target);
}
}migration-demo.schema-target=2 stops Flyway after the expand migration. Without this property, Flyway runs to the latest version, which is V4 here.
This property only supports the demo. For a real service, I would stage the migrations in the deployment pipeline. The bridge release must stop before any migration that breaks the old code. If it can reach V4, one bridge instance can remove full_name while old instances still use it.
Add the Three SQL Shapes
Create the response record at src/main/java/com/themainthread/flyway/domain/Customer.java:
package com.themainthread.flyway.domain;
public record Customer(long id, String email, String displayName) {
}Create src/main/java/com/themainthread/flyway/persistence/DatabaseOperationException.java:
package com.themainthread.flyway.persistence;
public class DatabaseOperationException extends RuntimeException {
public DatabaseOperationException(String message, Throwable cause) {
super(message, cause);
}
}Now add src/main/java/com/themainthread/flyway/persistence/CustomerRepository.java:
package com.themainthread.flyway.persistence;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;
import javax.sql.DataSource;
import com.themainthread.flyway.config.MigrationDemoConfig;
import com.themainthread.flyway.domain.Customer;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class CustomerRepository {
private final DataSource dataSource;
private final MigrationDemoConfig config;
@Inject
public CustomerRepository(DataSource dataSource, MigrationDemoConfig config) {
this.dataSource = dataSource;
this.config = config;
}
public Customer create(String email, String displayName) {
String sql = switch (config.release()) {
case LEGACY -> """
INSERT INTO customer (email, full_name)
VALUES (?, ?)
RETURNING id, email, full_name AS display_name
""";
case BRIDGE -> """
INSERT INTO customer (email, full_name, display_name)
VALUES (?, ?, ?)
RETURNING id, email, COALESCE(display_name, full_name) AS display_name
""";
case MODERN -> """
INSERT INTO customer (email, display_name)
VALUES (?, ?)
RETURNING id, email, display_name
""";
};
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, email);
statement.setString(2, displayName);
if (config.release() == MigrationDemoConfig.Release.BRIDGE) {
statement.setString(3, displayName);
}
try (ResultSet result = statement.executeQuery()) {
result.next();
return mapCustomer(result);
}
} catch (SQLException exception) {
throw new DatabaseOperationException("Could not create customer", exception);
}
}
public Optional<Customer> findById(long id) {
String sql = switch (config.release()) {
case LEGACY -> "SELECT id, email, full_name AS display_name FROM customer WHERE id = ?";
case BRIDGE -> "SELECT id, email, COALESCE(display_name, full_name) AS display_name FROM customer WHERE id = ?";
case MODERN -> "SELECT id, email, display_name FROM customer WHERE id = ?";
};
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setLong(1, id);
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
return Optional.empty();
}
return Optional.of(mapCustomer(result));
}
} catch (SQLException exception) {
throw new DatabaseOperationException("Could not read customer " + id, exception);
}
}
public Optional<Customer> rename(long id, String displayName) {
String sql = switch (config.release()) {
case LEGACY -> """
UPDATE customer
SET full_name = ?
WHERE id = ?
RETURNING id, email, full_name AS display_name
""";
case BRIDGE -> """
UPDATE customer
SET full_name = ?, display_name = ?
WHERE id = ?
RETURNING id, email, COALESCE(display_name, full_name) AS display_name
""";
case MODERN -> """
UPDATE customer
SET display_name = ?
WHERE id = ?
RETURNING id, email, display_name
""";
};
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, displayName);
if (config.release() == MigrationDemoConfig.Release.BRIDGE) {
statement.setString(2, displayName);
statement.setLong(3, id);
} else {
statement.setLong(2, id);
}
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
return Optional.empty();
}
return Optional.of(mapCustomer(result));
}
} catch (SQLException exception) {
throw new DatabaseOperationException("Could not rename customer " + id, exception);
}
}
private Customer mapCustomer(ResultSet result) throws SQLException {
return new Customer(
result.getLong("id"),
result.getString("email"),
result.getString("display_name"));
}
}I kept the release switch visible so we can see every SQL shape. The legacy path proves that V2 still accepts the original SQL. The bridge path writes both columns and falls back to the old value when it reads. The modern path never references full_name, so it keeps working after V4.
In a real service, these changes ship across several application releases. First expand the database and deploy compatible code. Then move the data and deploy code that only uses the new column. Contract the database after the rollback window closes.
Expose the Customer API
Add the request records. Create src/main/java/com/themainthread/flyway/api/CreateCustomerRequest.java:
package com.themainthread.flyway.api;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record CreateCustomerRequest(
@NotBlank @Email String email,
@NotBlank String displayName) {
}Create src/main/java/com/themainthread/flyway/api/RenameCustomerRequest.java:
package com.themainthread.flyway.api;
import jakarta.validation.constraints.NotBlank;
public record RenameCustomerRequest(@NotBlank String displayName) {
}Create src/main/java/com/themainthread/flyway/api/CustomerResource.java:
package com.themainthread.flyway.api;
import com.themainthread.flyway.domain.Customer;
import com.themainthread.flyway.persistence.CustomerRepository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@ApplicationScoped
@Path("/customers")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class CustomerResource {
private final CustomerRepository repository;
@Inject
public CustomerResource(CustomerRepository repository) {
this.repository = repository;
}
@POST
public Response create(@Valid CreateCustomerRequest request) {
Customer customer = repository.create(request.email(), request.displayName());
return Response.status(Response.Status.CREATED).entity(customer).build();
}
@GET
@Path("/{id}")
public Customer findById(@PathParam("id") long id) {
return repository.findById(id).orElseThrow(NotFoundException::new);
}
@PUT
@Path("/{id}/name")
public Customer rename(@PathParam("id") long id, @Valid RenameCustomerRequest request) {
return repository.rename(id, request.displayName()).orElseThrow(NotFoundException::new);
}
}Each operation uses one prepared statement. With JDBC auto-commit, each statement runs in its own database transaction. I use direct JDBC here because it keeps every column reference visible.
Configure Flyway and Dev Services
Replace src/main/resources/application.properties with:
quarkus.datasource.db-kind=postgresql
quarkus.datasource.devservices.image-name=docker.io/library/postgres:18.4-alpine
quarkus.flyway.migrate-at-start=true
quarkus.flyway.validate-migration-naming=true
quarkus.flyway.connect-retries=10
quarkus.flyway.connect-retries-interval=5s
migration-demo.release=MODERN
%test.quarkus.flyway.migrate-at-start=falsemigrate-at-start keeps local runs simple. validate-migration-naming fails startup when a migration file has the wrong name. Flyway retries the first connection up to 10 times and waits no more than five seconds between attempts. This covers the common case where the application starts a little faster than the database, but startup still has a time limit.
The test profile disables automatic migration because MigrationPathTest moves one PostgreSQL database through V1, V2, V3, and V4. The HTTP tests override the setting and start at the schema version they need.
We do not enable baseline-on-migrate. This is a new schema with a complete migration history. If the application finds a populated database with no Flyway history, it should fail because it is probably connected to the wrong database.
Run Two Releases Against V2
Package the application:
./mvnw package -DskipTestsStart PostgreSQL 18.4:
podman run --name flyway-zero-downtime-db --replace --detach \
--publish 5432:5432 \
--env POSTGRES_DB=customers \
--env POSTGRES_USER=customers \
--env POSTGRES_PASSWORD=customers \
docker.io/library/postgres:18.4-alpineOpen a terminal for the legacy release:
java \
-Dquarkus.http.port=8081 \
-Dquarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/customers \
-Dquarkus.datasource.username=customers \
-Dquarkus.datasource.password=customers \
-Dmigration-demo.release=LEGACY \
-Dmigration-demo.schema-target=2 \
-jar target/quarkus-app/quarkus-run.jarOpen another terminal for the bridge release:
java \
-Dquarkus.http.port=8082 \
-Dquarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/customers \
-Dquarkus.datasource.username=customers \
-Dquarkus.datasource.password=customers \
-Dmigration-demo.release=BRIDGE \
-Dmigration-demo.schema-target=2 \
-jar target/quarkus-app/quarkus-run.jarBoth processes may call migrate. Flyway uses a database lock to run the migration work one process at a time. One process applies V1 and V2. The other sees that the schema is already current.
Create a customer through the legacy process:
curl --fail-with-body \
--header 'Content-Type: application/json' \
--data '{"email":"grace@example.com","displayName":"Grace Hopper"}' \
http://localhost:8081/customersExpected response:
{
"displayName": "Grace Hopper",
"email": "grace@example.com",
"id": 1
}Read the same row through the bridge process:
curl --fail-with-body http://localhost:8082/customers/1The response is identical. The legacy insert only supplied full_name, and the trigger copied it to display_name before PostgreSQL wrote the row.
Now update it through the legacy process and read it through the bridge process:
curl --fail-with-body \
--request PUT \
--header 'Content-Type: application/json' \
--data '{"displayName":"Rear Admiral Grace Hopper"}' \
http://localhost:8081/customers/1/name
curl --fail-with-body http://localhost:8082/customers/1Expected bridge response:
{
"displayName": "Rear Admiral Grace Hopper",
"email": "grace@example.com",
"id": 1
}The second read must return the updated name. This checks that the two application versions cannot leave stale values in one of the columns.
Backfill After the Legacy Release Stops
Stop the legacy process and wait until the deployment platform confirms that no old instances remain. We can now move every existing row to the new column with V3.
Create src/main/resources/db/migration/V3__backfill_and_require_display_name.sql:
SET LOCAL lock_timeout = '5s';
UPDATE customer
SET display_name = full_name
WHERE display_name IS NULL;
ALTER TABLE customer
ADD CONSTRAINT customer_display_name_present
CHECK (display_name IS NOT NULL) NOT VALID;
ALTER TABLE customer
VALIDATE CONSTRAINT customer_display_name_present;
ALTER TABLE customer
ALTER COLUMN display_name SET NOT NULL;
ALTER TABLE customer
DROP CONSTRAINT customer_display_name_present;The UPDATE handles rows created before V2. Rows created during the mixed-version stage already have both values because the trigger covers legacy and bridge writes.
PostgreSQL adds the CHECK constraint with NOT VALID, so it does not scan all existing rows while it takes the initial catalog lock. VALIDATE CONSTRAINT checks the old rows in a separate step and allows concurrent updates during the scan. The validated check proves that the column has no nulls. PostgreSQL can then run SET NOT NULL without another full-table scan. The PostgreSQL ALTER TABLE reference documents the sequence and its lock behavior.
The migration still has to acquire locks. Many forms of ALTER TABLE use strong locks, and another transaction may already hold a conflicting lock. SET LOCAL lock_timeout = '5s' fails the migration attempt after five seconds. The deployment can stop and retry when the database is less busy. Set this limit according to your deployment timeout and database workload.
One large UPDATE creates a large transaction and a lot of write-ahead log (WAL). For a large table, run the backfill in batches from application or job code. The demo keeps the update in V3 because its table is small and the full transition stays easy to reproduce.
Start the modern process against V3 on port 8083:
java \
-Dquarkus.http.port=8083 \
-Dquarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/customers \
-Dquarkus.datasource.username=customers \
-Dquarkus.datasource.password=customers \
-Dmigration-demo.release=MODERN \
-Dmigration-demo.schema-target=3 \
-jar target/quarkus-app/quarkus-run.jarThe compatibility trigger remains in V3. A modern insert only supplies display_name, and the trigger fills full_name. You can still roll back to the bridge release while you watch the modern release in production.
Contract After the Rollback Window
Keep the old column until the modern release is stable and the rollback plan no longer starts code that references full_name.
Create src/main/resources/db/migration/V4__contract_remove_full_name.sql:
SET LOCAL lock_timeout = '5s';
DROP TRIGGER customer_name_compatibility ON customer;
DROP FUNCTION sync_customer_name_columns();
ALTER TABLE customer DROP COLUMN full_name;V4 removes the compatibility trigger and then drops the old column. The modern repository only reads and writes display_name, so it continues to work. LEGACY and BRIDGE fail against V4 because the rollback window is now closed.
In a real delivery pipeline, V4 belongs to a later release. Before it runs, confirm that the old ReplicaSet is gone and that rollback automation points to a compatible build. Background jobs and reporting processes must also use the new column.
Run the Compatibility Tests
The project has three database-backed test slices:
MigrationPathTestadvances one database through every safe stage and separately proves that the direct rename breaks legacy SQLBridgeReleaseResourceTestruns the API at V2 and checks legacy writes, bridge writes, and legacy updatesModernReleaseResourceTestruns the API at V4 and verifies thatfull_nameis gone
Run them with Podman available:
./mvnw testExpected summary:
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSThe failure test checks PostgreSQL SQLSTATE 42703. The compatible path inspects both columns at V2 and confirms that V3 preserved data created before the expansion. It also inserts a modern row at V3 and checks every display_name after V4.
The tests run on PostgreSQL 18.4 through Quarkus Dev Services. H2 cannot test the PostgreSQL trigger, constraint validation, SQLSTATE, or lock behavior used here.
What the Demo Proves
The tests prove schema compatibility across application versions. Flyway provides the migration order and checksums, history and database locking. PostgreSQL controls DDL locks and transaction behavior. Each application release defines which schema versions it can use. A zero-downtime rollout depends on all three.
For Kubernetes, I use one Flyway initialization task and make the application replicas wait for it. Quarkus can generate a Flyway Job and a waiting init container, as described in the Quarkus initialization task guide. This defines which process runs the migration.
Keep Flyway validation enabled. Editing an applied migration changes its checksum and stops the next migration run. Put the next schema change in V5. repair only changes Flyway’s schema-history metadata. It cannot make a breaking schema compatible with a running application.
Conclusion
The column rename took one line of PostgreSQL. Shipping it safely took an expand migration, compatible application code, a backfill, and a later contract migration. Flyway kept those schema versions in order. The application code and temporary trigger kept old and new releases working during the rollout.
Sometimes a simple one-line change does indeed trigger a massive amount of work. Code is cheap. Software is not ;-)


