Most database migrations cause downtime or data loss if done wrong. Learn the expand-contract pattern with Flyway for schema changes that deploy without taking your app offline.
JOptimize Team
A database migration that adds a NOT NULL column to a 50-million-row table will lock the entire table for minutes. Dropping a column while old code still reads it will throw exceptions immediately. Renaming a column is the most dangerous migration of all — old and new code can't coexist during the rollout window. These aren't edge cases. They're the migrations developers run every week.
The expand-contract pattern solves all of these without downtime.
Every schema change that could break a running application is split into three phases:
Phase 1 — EXPAND: Add new structure alongside old (both work simultaneously) Phase 2 — MIGRATE: Move data from old to new structure, deploy new code Phase 3 — CONTRACT: Remove old structure (after all instances run new code)
Each phase is a separate deployment. No phase breaks the running application.
Dangerous way (causes downtime):
ALTER TABLE users RENAME COLUMN first_name TO given_name; -- Old code: reads 'first_name' → NullPointerException everywhere
Safe way — Expand-Contract:
-- Phase 1: EXPAND — add new column, keep old -- V2__add_given_name_column.sql ALTER TABLE users ADD COLUMN given_name VARCHAR(100); UPDATE users SET given_name = first_name; -- Backfill existing data
// Application code: write to BOTH columns, read from new @Entity public class User { @Column(name = "first_name") // Keep old private String firstName; @Column(name = "given_name") // Read new private String givenName; public void setName(String name) { this.firstName = name; // Write both during transition this.givenName = name; } }
-- Phase 3: CONTRACT — after all instances use new column (separate deploy) -- V3__drop_first_name_column.sql ALTER TABLE users DROP COLUMN first_name;
-- DANGEROUS — locks table, fails if any row exists ALTER TABLE orders ADD COLUMN region VARCHAR(50) NOT NULL; -- SAFE — 3 phases -- Phase 1: Add nullable (no lock, instant) -- V4__add_region_nullable.sql ALTER TABLE orders ADD COLUMN region VARCHAR(50); -- Backfill in batches to avoid locking DO $$ DECLARE batch_size INT := 10000; BEGIN LOOP UPDATE orders SET region = 'US' WHERE region IS NULL LIMIT batch_size; EXIT WHEN NOT FOUND; PERFORM pg_sleep(0.1); -- Brief pause between batches END LOOP; END $$; -- Phase 3: Add NOT NULL constraint (after all rows populated + new code deployed) -- V5__region_not_null.sql ALTER TABLE orders ALTER COLUMN region SET NOT NULL;
-- Before: users table has address columns mixed with user data -- Goal: extract to separate addresses table -- Phase 1: EXPAND — create new table -- V6__create_addresses_table.sql CREATE TABLE addresses ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id), street VARCHAR(255), city VARCHAR(100), country VARCHAR(2), UNIQUE(user_id) -- One address per user for now ); -- Backfill existing data INSERT INTO addresses (user_id, street, city, country) SELECT id, street, city, country FROM users WHERE street IS NOT NULL;
// Write to both during transition @Transactional public void updateAddress(Long userId, AddressDto address) { User user = userRepo.findById(userId).orElseThrow(); user.setStreet(address.getStreet()); // Old columns user.setCity(address.getCity()); userRepo.save(user); addressRepo.findByUserId(userId) // New table .ifPresentOrElse( a -> addressRepo.save(a.update(address)), () -> addressRepo.save(new Address(userId, address)) ); }
-- Phase 3: DROP old columns -- V7__drop_address_columns_from_users.sql ALTER TABLE users DROP COLUMN street, DROP COLUMN city, DROP COLUMN country;
# application.properties # Validate migrations on startup (fail fast if migration is missing) spring.flyway.validate-on-migrate=true # Baseline for existing DBs with existing schema spring.flyway.baseline-on-migrate=false # Only set true once for existing DBs # Repair checksum mismatches (use with care) spring.flyway.repair-on-migrate=false # Location of migration scripts spring.flyway.locations=classpath:db/migration
Migration naming:
V1__initial_schema.sql V2__add_given_name_column.sql V3__drop_first_name_column.sql ← Only in next release R__refresh_materialized_view.sql ← Repeatable migration (runs every time checksum changes)
// Use a batch job instead of a SQL UPDATE on the entire table @Component @RequiredArgsConstructor public class RegionBackfillJob { private final JdbcTemplate jdbc; @Scheduled(fixedDelay = 5000) @Transactional public void backfillRegion() { int updated = jdbc.update( "UPDATE orders SET region = 'US' WHERE region IS NULL LIMIT 5000" ); if (updated > 0) { log.info("Backfilled {} orders", updated); } } }
Batching in 5000-row chunks with a delay between batches keeps the backfill from locking the table or exhausting I/O.
Zero-downtime migrations use the expand-contract pattern: add new structure, migrate data in batches, deploy new code, then remove old structure in a separate release. Never add NOT NULL without backfilling first, never drop a column in the same release that stops using it, and always batch large backfills to avoid table locks. With Flyway and this pattern, schema changes become non-events — deployable any time without planned downtime.
JOptimize analyzes your Flyway migration scripts for NOT NULL additions without defaults, missing indexes on foreign keys, and destructive operations that risk downtime.
Deploy database changes safely, every time — free scan.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.