Published on
· Updated

Zero-Downtime Schema Migration with Spring Boot, PostgreSQL, and Kafka

Authors

A database migration can finish successfully and still cause an outage.

The SQL may be valid, but an older application instance may still expect the previous column. A backfill may saturate PostgreSQL I/O. A new Kafka producer may publish an event that an old consumer cannot deserialize. A direct dual write may update one data store and fail before updating the other. A rollback may restore application code while leaving the database in an incompatible state.

For a continuously available service, schema evolution is therefore a deployment protocol, not a single migration file.

This guide uses an expand-migrate-contract sequence for Spring Boot 4.1, PostgreSQL, Kafka, Flyway, and Debezium. The goal is not to promise that every DDL statement is lock-free. The goal is to keep each rollout phase compatible, observable, bounded, and recoverable.

TL;DR Expand the schema before new code depends on it. Run old and new representations together while data is backfilled and validated. Switch reads only after the new representation is complete. Remove the old contract in a later deployment, after every old writer and consumer has disappeared.

Define the Migration Contract First

Before writing SQL, answer these questions:

  • What is the source of truth during each phase?
  • Which application versions may run together?
  • Can old code read the expanded schema?
  • Can new code process old rows and old events?
  • Which writes are authoritative?
  • How is historical data copied?
  • How are concurrent writes captured during backfill?
  • What proves that source and target are equivalent?
  • What is the rollback point for each phase?
  • When is destructive cleanup allowed?

A useful migration record contains:

migration name
owner
source and target
expected row count
deployment phases
compatibility window
backfill query
throttle limits
validation queries
cutover criteria
rollback or roll-forward plan
cleanup date

Without these decisions, “zero downtime” usually means “run the migration and watch the dashboards.”

Use Expand, Migrate, and Contract

The safest general sequence is:

Expand
  Add compatible schema and code paths

Migrate
  Backfill historical data
  Capture concurrent changes
  Validate equivalence

Cut over
  Switch reads and ownership gradually

Contract
  Stop old writes
  Remove old code
  Remove old schema in a later release

The compatibility window is intentional. During it, more than one application version may be live.

Example: Rename user_name to full_name

A direct rename is concise:

ALTER TABLE users
    RENAME COLUMN user_name TO full_name;

It is not compatible with old application instances that still query user_name.

Use two columns temporarily.

Phase 1: Expand the database

ALTER TABLE users
    ADD COLUMN full_name VARCHAR(255);

This is additive. Old application versions continue using user_name.

Adding a nullable column is usually a safer first step than immediately attaching a new invariant. PostgreSQL still acquires a table lock for ALTER TABLE, so deploy during a period when lock acquisition can be monitored and bounded.

For PostgreSQL 11 and later, adding a column with a constant default can avoid rewriting every row. That does not make every default cheap: a volatile default can require evaluating and storing a value for each row, and the DDL still needs the required lock.

Phase 2: Deploy compatible application code

Map both columns:

@Entity
@Table(name = "users")
public class User {

    @Id
    private Long id;

    @Column(name = "user_name")
    private String legacyUserName;

    @Column(name = "full_name")
    private String fullName;

    public String displayName() {
        return fullName != null
                ? fullName
                : legacyUserName;
    }

    public void changeDisplayName(String value) {
        this.legacyUserName = value;
        this.fullName = value;
    }
}

This intermediate version:

  • reads the new value first;
  • falls back to the old value;
  • writes both columns in the same PostgreSQL transaction.

Dual writing two columns in one row and one database transaction is very different from writing two independent databases. The former can be atomic. The latter cannot be made atomic by one ordinary Spring @Transactional annotation.

Phase 3: Backfill existing rows

Do not run one unbounded update on a large production table without evaluating lock duration, WAL volume, replication lag, vacuum pressure, and rollback cost.

Process bounded batches:

WITH batch AS (
    SELECT id
    FROM users
    WHERE full_name IS NULL
    ORDER BY id
    LIMIT 1000
    FOR UPDATE SKIP LOCKED
)
UPDATE users AS target
SET full_name = target.user_name
FROM batch
WHERE target.id = batch.id
RETURNING target.id;

Repeat until no rows are returned.

Benefits:

  • each transaction is short;
  • work can be throttled;
  • several workers can claim different rows;
  • progress is measurable;
  • the migration can pause without losing completed work.

The migration remains idempotent because rows with an existing full_name are skipped.

Phase 4: Validate

At minimum:

SELECT COUNT(*) AS missing_full_name
FROM users
WHERE full_name IS NULL;

SELECT COUNT(*) AS mismatched_names
FROM users
WHERE full_name IS DISTINCT FROM user_name;

For transformed data, compare more than counts:

  • deterministic checksums by key range;
  • sampled values;
  • aggregate totals;
  • domain invariants;
  • recently modified records;
  • records written during the backfill;
  • replicas and downstream projections.

A zero discrepancy count at one moment is not enough if an old application can still write only the legacy column.

Phase 5: Switch reads

Change the application to read only full_name, while continuing to write both columns temporarily.

A feature flag can reduce blast radius:

@Component
public class UserNameReader {

    private final MigrationFlags flags;

    public String read(User user) {
        if (flags.readFullName()) {
            return user.getFullName();
        }

        return user.getLegacyUserName();
    }
}

Use stable cohorts for canarying. Randomly switching the read path on every request makes discrepancies difficult to reproduce.

Phase 6: Enforce the new invariant

After backfill and write-path verification, add and validate a check constraint:

ALTER TABLE users
    ADD CONSTRAINT users_full_name_not_null
    CHECK (full_name IS NOT NULL)
    NOT VALID;

The constraint applies to new and changed rows while existing rows remain unvalidated.

Validate it separately:

ALTER TABLE users
    VALIDATE CONSTRAINT users_full_name_not_null;

Then convert the column property:

ALTER TABLE users
    ALTER COLUMN full_name SET NOT NULL;

When PostgreSQL can use a valid constraint proving the column contains no nulls, it can avoid a redundant full-table validation scan for SET NOT NULL.

Finally, remove the temporary check if it is no longer useful:

ALTER TABLE users
    DROP CONSTRAINT users_full_name_not_null;

Phase 7: Contract later

Only after all old deployments and background jobs have stopped using user_name:

ALTER TABLE users
    DROP COLUMN user_name;

The destructive migration belongs in a later release. Once a column is dropped, rolling application code back may not restore compatibility.

Large Indexes Need a Separate Plan

A normal index build blocks writes to the table while the index is created.

For a busy production table:

CREATE INDEX CONCURRENTLY idx_users_full_name
    ON users (full_name);

CREATE INDEX CONCURRENTLY allows normal writes to continue, but it takes longer and performs additional table scans. It also cannot run inside a transaction block.

A failed concurrent build may leave an invalid index. Verify it:

SELECT
    indexrelid::regclass AS index_name,
    indisvalid,
    indisready
FROM pg_index
WHERE indexrelid =
      'idx_users_full_name'::regclass;

Drop and retry an invalid index after diagnosing the failure:

DROP INDEX CONCURRENTLY IF EXISTS idx_users_full_name;

When Flyway normally wraps migrations in transactions, place non-transactional DDL in a migration configured not to run in a transaction. Keep it separate from transactional changes so failure behavior is obvious.

Use Flyway as the Schema Authority

Spring Boot supports Flyway and Liquibase for versioned database changes.

For PostgreSQL with Spring Boot 4.1:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-flyway</artifactId>
</dependency>

<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>

Do not use Hibernate ddl-auto=update as the production migration system.

spring:
  jpa:
    hibernate:
      ddl-auto: validate

  flyway:
    enabled: true

validate lets Hibernate detect an incompatible mapping without independently changing the schema.

Migration rules

  • Never edit a migration that has already run in shared environments.
  • Create a new versioned migration for every change.
  • Separate DDL with different transactional requirements.
  • Review lock and rewrite behavior, not just SQL syntax.
  • Test against production-like row counts.
  • Record execution time and replication impact.
  • Make destructive migrations visibly delayed.

Avoid Cross-Database Dual Writes

This code does not create one atomic transaction across two independently owned databases:

@Transactional
public void updateProduct(Product product) {
    oldRepository.save(product);
    newRepository.save(convert(product));
}

Unless a deliberately configured distributed transaction protocol covers both resources, one write can commit while the other fails.

The same problem appears here:

@Transactional
public void updateProduct(Product product) {
    repository.save(product);
    kafkaTemplate.send("product.events", product.id(), product);
}

The database and Kafka do not share one local transaction.

Prefer Transactional Outbox for Application-Owned Changes

Write the business change and an outbox row in one PostgreSQL transaction:

CREATE TABLE outbox_events (
    id UUID PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(200) NOT NULL,
    event_type VARCHAR(150) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    published_at TIMESTAMPTZ
);
@Service
public class ProductService {

    private final ProductRepository products;
    private final OutboxRepository outbox;

    @Transactional
    public void update(UpdateProductCommand command) {
        Product product = products
                .findById(command.productId())
                .orElseThrow();

        product.apply(command);

        outbox.save(
                OutboxEvent.productUpdated(product)
        );
    }
}

A relay publishes committed rows to Kafka at least once. The target consumer must be idempotent.

For a service split:

Old product database
    -> outbox event
    -> Kafka
    -> catalog projection
    -> inventory projection

The old service remains the write owner until cutover. Do not let old and new services independently accept authoritative writes to the same aggregate without an ownership rule.

Use CDC When Source Code Should Not Dual Write

Debezium can capture committed PostgreSQL row changes from the WAL.

Typical flow:

PostgreSQL
  -> Debezium PostgreSQL connector
  -> Kafka change-event topics
  -> idempotent migration consumer
  -> target database

The PostgreSQL connector can take an initial consistent snapshot and then continue streaming from the corresponding log position.

CDC is helpful when:

  • changing the source application is risky;
  • a large historical snapshot and live updates must be combined;
  • several projections need the same database changes;
  • a legacy system cannot publish domain events.

CDC records are database change facts, not automatically well-designed domain events. They expose table-level structure and can couple consumers to the source schema.

Expect duplicates

Connector restart and snapshot boundaries can produce duplicate records. A migration consumer must use an idempotent key and upsert semantics.

CREATE TABLE user_profile_projection (
    user_id BIGINT PRIMARY KEY,
    full_name VARCHAR(255) NOT NULL,
    source_lsn VARCHAR(100) NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL
);

A consumer can reject older source positions or revisions when ordering is known.

Do not acknowledge the Kafka record after logging a database failure. Let the listener fail so retry or dead-letter handling can run.

Monitor the replication slot

A PostgreSQL logical replication slot can retain WAL required by the connector. If the connector is unavailable for too long, retained WAL can consume significant disk space.

Monitor:

  • connector task state;
  • source LSN and processed LSN;
  • replication-slot retained bytes;
  • Kafka Connect offset health;
  • snapshot progress;
  • consumer lag;
  • target-application errors.

Event Schema Evolution Is a Contract

Database compatibility and event compatibility are separate.

For Avro, Protobuf, or JSON Schema with Schema Registry, choose and enforce a compatibility mode. The meaning of backward and forward compatibility depends on which producer and consumer versions coexist.

A generally safe deployment sequence is:

  1. update consumers so they can read the existing and expanded schema;
  2. register a compatible schema;
  3. update producers to write the new field;
  4. wait for old consumers to disappear;
  5. remove deprecated fields only when the configured compatibility rules allow it.

Additive change example

Old event:

{
  "orderId": "o-123",
  "customerId": "c-42"
}

Expanded event:

{
  "orderId": "o-123",
  "customerId": "c-42",
  "salesChannel": "MOBILE"
}

The new field needs semantics that old and new readers can handle. In schema-managed formats, defaults and optionality must follow that format's compatibility rules.

Semantic changes deserve a new event

Changing:

OrderTotalUpdated

from gross amount to net amount is not safe merely because the JSON field remains a decimal.

When meaning changes substantially:

  • publish a new event type;
  • use a new topic when lifecycle and retention differ;
  • run old and new consumers in parallel;
  • compare outputs;
  • retire the old contract later.

A numeric schemaVersion field can help route payloads, but it does not enforce compatibility or document semantics by itself.

Replay Kafka Events Safely

A new consumer group can rebuild a projection from retained events:

spring:
  kafka:
    consumer:
      group-id: user-profile-rebuild-v2
      auto-offset-reset: earliest

earliest applies when the group has no committed offset. It does not restore data that the topic's retention policy has already deleted.

A replay design needs:

  • sufficient topic retention;
  • every historical event schema;
  • deterministic projection logic;
  • idempotent database writes;
  • a separate consumer group;
  • progress and lag monitoring;
  • a plan for live catch-up and cutover.

Do not hardcode only partition 0 in a @KafkaListener and describe it as a full replay. A topic can have many partitions and can be repartitioned over time.

For high-volume rebuilds, publish to a new target table or schema. Switch readers after validation rather than mutating the live projection in place.

Build an Idempotent Backfill Worker

A Spring Batch job is appropriate when the migration needs restartability, chunk processing, execution metadata, and bounded transactions.

A simpler worker can still persist a cursor:

CREATE TABLE migration_progress (
    migration_name VARCHAR(150) PRIMARY KEY,
    last_processed_id BIGINT NOT NULL,
    rows_processed BIGINT NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL
);

Use keyset pagination rather than increasing OFFSET values:

SELECT id, user_name
FROM users
WHERE id > :last_processed_id
ORDER BY id
LIMIT :batch_size;

The target write should be idempotent:

INSERT INTO user_profiles (
    user_id,
    full_name,
    source_version
)
VALUES (
    :user_id,
    :full_name,
    :source_version
)
ON CONFLICT (user_id)
DO UPDATE SET
    full_name = EXCLUDED.full_name,
    source_version = EXCLUDED.source_version
WHERE user_profiles.source_version
      < EXCLUDED.source_version;

This protects against retries and stale updates.

Bound Concurrency

Java virtual threads make blocking I/O easier to express, but they do not increase PostgreSQL connection-pool capacity or Kafka partition parallelism.

Do not start an untracked virtual thread inside a Kafka listener and return immediately:

@KafkaListener(topics = "migration.events")
public void consume(MigrationEvent event) {
    Thread.ofVirtual().start(
            () -> migrate(event)
    );
}

The listener may return and advance its offset before the detached work succeeds.

Process synchronously within the listener's delivery contract, or use a managed executor with explicit completion and failure handling.

For a backfill, concurrency should be bounded by:

  • database connection-pool size;
  • target write capacity;
  • replication lag;
  • lock contention;
  • CPU cost of transformation;
  • acceptable production latency impact.

More threads can make the migration slower by saturating the database.

Validation Gates Before Cutover

Do not cut over because the backfill job reached 100%.

Use several independent signals.

Completeness

SELECT COUNT(*)
FROM source_users;

SELECT COUNT(*)
FROM target_user_profiles;

Key coverage

SELECT source.id
FROM users AS source
LEFT JOIN user_profiles AS target
       ON target.user_id = source.id
WHERE target.user_id IS NULL
LIMIT 100;

Value comparison

SELECT source.id
FROM users AS source
JOIN user_profiles AS target
  ON target.user_id = source.id
WHERE target.full_name
      IS DISTINCT FROM source.user_name
LIMIT 100;

Freshness

Measure the delay between the latest source commit and target application.

Application shadow reads

Read both representations for a small sample, return the old result, and record whether the new result matches.

Do not log sensitive field values merely to compare them. Log identifiers, hashes, mismatch categories, and counts.

Cutover Strategy

A controlled cutover can follow this order:

  1. new schema exists;
  2. compatible application writes old and new representations;
  3. backfill completes;
  4. validation remains clean while live writes continue;
  5. a small cohort reads from the new path;
  6. new-read percentage increases;
  7. the new path becomes default;
  8. old writes stop;
  9. a soak period passes;
  10. old columns, topics, and code are removed later.

For service extraction, define one write owner at every step.

Before cutover: old service owns writes
After cutover:  new service owns writes
Never:          both own the same aggregate without coordination

Rollback Is Phase-Specific

Before destructive cleanup, rollback often means:

  • disable the new read flag;
  • route traffic to the old service;
  • keep dual writes active;
  • pause the backfill;
  • preserve expanded columns and tables.

After the old representation is dropped, restoring old code may not work. The recovery strategy may become roll-forward or restore from a tested backup.

A database backup is not an instant rollback button. Restoring it can discard writes accepted after the backup point and can require a separate outage or reconciliation process.

Observability During Migration

Track:

  • migration rows processed per second;
  • remaining rows;
  • batch latency and failure rate;
  • PostgreSQL CPU, I/O, locks, and connection use;
  • WAL generation and replica lag;
  • index-build progress;
  • CDC snapshot and streaming state;
  • replication-slot retained WAL;
  • Kafka consumer lag;
  • dead-letter count;
  • source-target discrepancy count;
  • old-column-only writes;
  • read-path error and latency by feature-flag cohort.

Useful operational alerts include:

backfill has made no progress for 10 minutes
replica lag exceeds recovery objective
target mismatch count increases
old writer activity appears after cutover
CDC connector is down while slot retention grows
new read path has higher error rate

Common Mistakes

“Adding a column with a default is always harmless”

Constant defaults can avoid a table rewrite on modern PostgreSQL, but DDL still takes locks, and volatile defaults can require a rewrite.

“Dual write keeps two services consistent”

Only writes inside one transactional resource are naturally atomic. Use outbox or CDC for independent stores and make the target idempotent.

“A Kafka version field guarantees compatibility”

It identifies a version. It does not prove that old and new readers can deserialize or interpret it safely.

“A completed backfill means cutover is safe”

Concurrent writes, stale events, and old writers can recreate discrepancies after the backfill finishes.

“Rollback means deploying the old application”

The old application may no longer match the expanded or contracted database schema.

“Virtual threads make the migration unlimited”

Database and broker capacity remain finite.

Migration Checklist

Before expansion:

  • compatibility matrix documented;
  • lock and rewrite behavior reviewed;
  • backup and restore tested;
  • dashboards and alerts ready;
  • migration can pause safely.

Before cutover:

  • backfill complete;
  • live updates captured;
  • discrepancy checks clean;
  • old and new read results compared;
  • target lag within objective;
  • rollback flag tested.

Before contraction:

  • no old application instances;
  • no old scheduled jobs;
  • no consumers using the old event;
  • no writes to the legacy column;
  • retention window and soak period complete;
  • destructive migration approved separately.

Conclusion

Zero-downtime schema evolution is achieved through compatibility and sequencing, not one clever SQL statement.

For Spring Boot, PostgreSQL, and Kafka:

  • expand before depending on new schema;
  • run mixed application versions safely;
  • backfill in bounded, idempotent chunks;
  • use a transactional outbox or CDC instead of unsafe cross-system dual writes;
  • treat Kafka schemas as independent contracts;
  • replay through a new consumer group only when retained history is complete;
  • validate source and target continuously;
  • cut over gradually;
  • delay destructive cleanup;
  • define rollback separately for every phase.

A migration is complete only after the new path is authoritative, discrepancies remain at zero, and the old contract has been removed without breaking any remaining producer, consumer, job, or operator workflow.

Official References