Database Migrations for Java: Flyway, Liquibase & Zero-Downtime Changes

Application code can be blue-green deployed and instantly rolled back. The database cannot — it holds state, it’s shared, and a bad schema change can corrupt data or take the whole service down. That’s why how you evolve your schema is one of the highest-stakes decisions in enterprise Java. This deep dive covers versioned migrations with Flyway and Liquibase, their Spring Boot integration, and the expand/contract pattern that makes schema changes safe during rolling, zero-downtime deploys.

TL;DR: Never change schemas by hand — use versioned, automated migrations (Flyway or Liquibase) checked into Git and applied on deploy. Flyway is SQL-first and simple; Liquibase is abstracted and multi-database. For zero downtime, use the expand/contract (parallel change) pattern so the old and new application versions both work against the schema during a rolling deploy.
Tailor your resume to a backend / data role →
flowchart LR
  Ex[Expand: add new column] --> Mig[Migrate: write both + backfill]
  Mig --> Move[Move reads to new column]
  Move --> Con[Contract: drop old column]
        
Expand and contract: each step stays backward-compatible while old and new app versions coexist.

Why hand-run SQL doesn’t scale

Manually applying SQL across environments is how teams end up with “works in staging, breaks in prod” schema drift, undocumented changes, and migrations nobody can reproduce. Schema changes need the same rigor as code: versioned, in source control, code-reviewed, automatically applied, and repeatable in every environment. That’s exactly what migration tools provide.

Flyway: SQL-first migrations

Flyway applies versioned SQL scripts in order and records what it has run in a flyway_schema_history table, so each migration runs exactly once per database. With Spring Boot, just add the dependency and drop numbered scripts in src/main/resources/db/migration — they run automatically on startup.

-- V1__create_orders.sql
CREATE TABLE orders (
  id        BIGINT PRIMARY KEY,
  sku       VARCHAR(64) NOT NULL,
  quantity  INT NOT NULL,
  status    VARCHAR(32) NOT NULL
);

-- V2__add_orders_created_at.sql
ALTER TABLE orders ADD COLUMN created_at TIMESTAMP;

The model is simple and transparent: it’s just SQL, so you have full control and can review exactly what hits the database. The trade-off is that you write database-specific SQL and rollbacks are something you script yourself (or handle with a forward fix).

Liquibase: abstracted, multi-database changesets

Liquibase describes changes as changesets in XML, YAML, JSON, or SQL, and can generate database-specific SQL from a single abstract definition — useful when you must support multiple database engines. It tracks applied changesets in DATABASECHANGELOG and has first-class rollback support.

# db.changelog-master.yaml
databaseChangeLog:
  - changeSet:
      id: 2
      author: platform
      changes:
        - addColumn:
            tableName: orders
            columns:
              - column: { name: created_at, type: timestamp }
      rollback:
        - dropColumn: { tableName: orders, columnName: created_at }
FlywayLiquibase
FormatPlain SQL (+ Java)XML/YAML/JSON/SQL
Database abstractionNo — you write per-DB SQLYes — generates per-DB SQL
RollbackManual / forward fix (paid auto-undo)Built-in rollback definitions
FeelSimple, transparentPowerful, more abstraction

Pick Flyway for SQL-centric simplicity; pick Liquibase for multi-database abstraction and built-in rollbacks. Both integrate with Spring Boot and run on startup or as a pipeline step.

The hard part: zero-downtime changes during rolling deploys

Here’s the problem that catches teams out. During a rolling deploy, the old and new versions of your application run at the same time against the same database. So any schema change must be compatible with both versions simultaneously. A destructive one-step change — rename a column, drop it, make it NOT NULL — breaks the version that doesn’t expect it, causing errors mid-deploy.

Concretely: if V2 renames name to full_name in one migration, the still-running V1 pods query name and start throwing errors the instant the migration applies. You’ve caused an outage with a “simple” rename.

Expand and contract (parallel change)

The pattern that solves this splits a breaking change into a sequence of individually backward-compatible steps spread across releases:

  1. Expand — add the new structure without removing the old. (Add full_name as a nullable column; the old code ignores it.)
  2. Migrate code to write both — deploy a version that writes to both name and full_name and backfills existing rows. Both old and new schema shapes are valid.
  3. Move reads — deploy a version that reads from full_name. The old column still exists, so any lingering old pods keep working.
  4. Contract — in a later release, once nothing reads or writes the old column, drop name.
-- Release 1 (expand): additive, safe with old code
ALTER TABLE customer ADD COLUMN full_name VARCHAR(255);

-- Release 2 (backfill): runs while app writes both columns
UPDATE customer SET full_name = name WHERE full_name IS NULL;

-- Release 3 (contract): only after no code uses the old column
ALTER TABLE customer DROP COLUMN name;

It’s more steps and more discipline, but each step is independently safe and reversible, and the service never goes down. The same approach handles adding NOT NULL constraints (add nullable → backfill → add constraint), splitting tables, and changing types.

Operational guardrails

Where migrations run in the pipeline

Two common approaches: run migrations on application startup (simple, built into Spring Boot, but couples schema changes to app rollout and can race across replicas), or as a dedicated pipeline step / init job before the new version rolls out (more control, avoids replica races, fits GitOps). For anything beyond trivial apps, a controlled migration step — gated, observable, and ordered relative to the code deploy — is the safer choice, especially when combined with the expand/contract sequencing above.

Takeaways

Treat the database schema with the same discipline as code: versioned, reviewed, automated migrations via Flyway or Liquibase, never hand-run SQL. The real skill is sequencing — because old and new app versions coexist during a deploy, breaking changes must be decomposed into expand → migrate → contract steps that are each backward-compatible. Add guardrails for locks, immutability, realistic testing, and rollback, and schema evolution stops being the scariest part of a release.

Frequently asked questions

Should I use Flyway or Liquibase?
Both are solid. Flyway is simpler and SQL-first — you write plain versioned SQL scripts. Liquibase is more abstracted, describing changes in XML/YAML/JSON (or SQL) with database-agnostic changesets and richer rollback support. Choose Flyway for SQL-centric simplicity, Liquibase for multi-database abstraction and built-in rollbacks.

How do you change a database schema with zero downtime?
Use the expand/contract (parallel change) pattern: first expand the schema with backward-compatible additions, deploy code that writes to both old and new shapes, backfill data, switch reads to the new shape, then contract by removing the old column/table in a later release — so old and new app versions both work during a rolling deploy.

Land your next Java role — tailor your resume with AI →