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.
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]
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 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 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 }
| Flyway | Liquibase | |
|---|---|---|
| Format | Plain SQL (+ Java) | XML/YAML/JSON/SQL |
| Database abstraction | No — you write per-DB SQL | Yes — generates per-DB SQL |
| Rollback | Manual / forward fix (paid auto-undo) | Built-in rollback definitions |
| Feel | Simple, transparent | Powerful, 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.
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.
The pattern that solves this splits a breaking change into a sequence of individually backward-compatible steps spread across releases:
full_name as a nullable column; the old code ignores it.)name and full_name and backfills existing rows. Both old and new schema shapes are valid.full_name. The old column still exists, so any lingering old pods keep working.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.
ALTER or index build can lock writes for a long time. Use online/concurrent operations (e.g. CREATE INDEX CONCURRENTLY in PostgreSQL) and run heavy changes in low-traffic windows.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.
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.
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.