Your Database Broke. Now What?
Flyway wins for a single service with simple SQL-only migrations, and Atlas wins the moment you want schema-as-code with automatic diffs instead of hand-written rollback scripts.
You’re running a Postgres instance in your home lab. Someone, maybe you at 2 AM, runs a bad migration and half your schema is gone. You didn’t version it. You didn’t test it. You just hoped.
That’s what schema migration tools are for. They’re the diff-and-patch system for your database, except they work and don’t require you to manually track who changed what in a spreadsheet.
The big three are Liquibase, Flyway, and Atlas. They all solve the same problem differently. Pick the wrong one for your stack and you’ll be hand-rolling SQL in CI pipelines by next year.
Should You Pick Flyway, Liquibase, Or Atlas For Your Database?
| Flyway | Liquibase | Atlas | |
|---|---|---|---|
| Style | Imperative SQL files | SQL, YAML, or XML changesets | Declarative HCL or SQL |
| Rollbacks | You write them yourself | Auto-generated for most changes | Diff-based, shown before apply |
| Runtime | JVM required | JVM required | Single Go binary |
| Drift detection | No | Yes, via status command | Yes, via schema diff |
| Best fit | Single service, simple schema | Multi-service, compliance needs | New systems, schema-as-code |
The Big Picture: Declarative vs Imperative
Before we dig into each tool, here’s the foundational split that’ll define everything about your choice.
Imperative migrations are SQL files with explicit CREATE TABLE / ALTER TABLE / DROP COLUMN statements. You write the exact steps. Flyway uses this.
Declarative migrations describe your desired final schema state”this table should exist with these columns”and the tool figures out the steps. Liquibase and Atlas lean declarative (though Liquibase supports both).
Imperative is simpler to understand at first. You write SQL like you always have. But you’re responsible for rollbacks, backwards compatibility, and not breaking production.
Declarative is more powerful. The tool can detect conflicts, generate safe migration paths, and handle complex multi-step refactors. But it’s also opinionated about schema design.
It’s manual vs automatic transmission, basically. Manual gives you control. Automatic handles the traffic for you.
Flyway: The Simple Kid
Flyway is the tool you reach for when you want to stop thinking about schema versioning yesterday.
You create SQL migration files with names like V1__Initial_schema.sql, V2__Add_users_table.sql, V3__Create_indexes.sql. Flyway scans a directory, executes them in order against your database, and tracks which have run in a flyway_schema_history table.
That’s it. No YAML. No DSL. Just SQL.
Flyway Example
-- V1__create_posts.sqlCREATE TABLE posts ( id BIGSERIAL PRIMARY KEY, title VARCHAR(255) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX idx_posts_created_at ON posts(created_at);-- V2__add_author_column.sqlALTER TABLE posts ADD COLUMN author_id BIGINT NOT NULL DEFAULT 1;ALTER TABLE posts ADD CONSTRAINT fk_posts_author FOREIGN KEY (author_id) REFERENCES users(id);Flyway runs V1, then V2, and marks each as complete. If you run it again, it skips them. If a new migration V3 shows up, Flyway detects it and runs only that.
Flyway in CI
#!/bin/bashset -e
# Create database if not existspsql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='myapp'" | grep -q 1 || \ psql -U postgres -c "CREATE DATABASE myapp"
# Run migrationsflyway -url=jdbc:postgresql://localhost:5432/myapp \ -user=postgres \ -password="$DB_PASSWORD" \ -locations=filesystem:./migrations \ migrate
echo "Migrations applied"Pros
- Brain dead simple. SQL only. No learning curve.
- Predictable. You know exactly what’s running.
- Fast to learn. Junior devs can write migrations on day one.
- Works everywhere. Java-based, runs on any OS.
Cons
- You own rollbacks. Flyway won’t undo a bad migration, you have to write
V4__rollback_bad_change.sql. - No schema drift detection. If someone hand-edits the database, Flyway doesn’t know.
- Scaling gets messy. Multiple teams migrating the same database? Good luck with merge conflicts in
V27__...sql.
Liquibase: The Swiss Army Knife
Liquibase is the enterprise heavyweight. It supports both imperative (SQL) and declarative (YAML/XML) migrations, tracks schema state, detects drift, and can generate rollbacks automatically.
You define changes in YAML, XML, or SQL files (called changesets). Liquibase combines them into a single changelog, executes them in order, and tracks everything in databasechangelog and databasechangeloglock tables.
Liquibase Example (YAML)
databaseChangeLog: - changeSet: id: 1 author: sumguy changes: - createTable: tableName: posts columns: - column: name: id type: BIGINT autoIncrement: true constraints: primaryKey: true - column: name: title type: VARCHAR(255) constraints: nullable: false - column: name: created_at type: TIMESTAMP defaultValue: CURRENT_TIMESTAMP - createIndex: indexName: idx_posts_created_at tableName: posts columns: - column: name: created_at
- changeSet: id: 2 author: sumguy changes: - addColumn: tableName: posts columns: - column: name: author_id type: BIGINT constraints: nullable: false references: users(id) foreignKeyName: fk_posts_authorLiquibase in CI
#!/bin/bashset -e
# Run migrationsjava -jar liquibase.jar \ --url=jdbc:postgresql://localhost:5432/myapp \ --username=postgres \ --password="$DB_PASSWORD" \ --changeLogFile=db/changelog/db.changelog-master.yaml \ update
echo "Liquibase applied all pending changesets"Pros
- Declarative or imperative. Mix YAML changesets with raw SQL when needed.
- Automatic rollbacks. Liquibase can generate rollback scripts for most changes.
- Schema drift detection.
statuscommand shows what’s drifted and what’s pending. - Preconditions. Run a changeset only if a column exists, or a table doesn’t, etc.
- Multi-database. Same changelog runs on Postgres, MySQL, SQLite, Oracle.
Cons
- Steeper learning curve. YAML syntax, changesets, contexts, preconditions. More concepts to grok.
- Java dependency. You’re installing a JVM. Could be a blocker in lightweight stacks.
- Overhead. Even simple migrations require XML/YAML boilerplate.
- Lock table issues. On shaky connections, the
databasechangeloglocktable can deadlock. Saw this twice.
Atlas: The New Hotness
Atlas is the newcomer (active development since 2021). It’s declarative-first, cloud-native, and built in Go (so it’s a single binary, no JVM, no runtime).
Instead of writing migrations, you define your desired schema in a declarative format (HCL or plain SQL). Atlas figures out the migration path, shows you a diff, and applies it safely.
This is a different mental model: you’re not writing migrations, you’re declaring schema state.
Atlas Example (HCL)
schema "public" { comment = "The public schema"}
table "posts" { schema = schema.public
column "id" { type = bigserial null = false } column "title" { type = varchar(255) null = false } column "content" { type = text null = false } column "author_id" { type = bigint null = false } column "created_at" { type = timestamp default = sql("CURRENT_TIMESTAMP") }
primary_key { columns = [column.id] }
foreign_key "author" { columns = [column.author_id] ref_columns = [table.users.column.id] on_delete = SET_NULL }
index "idx_posts_created_at" { columns = [column.created_at] }}
table "users" { schema = schema.public
column "id" { type = bigserial } column "name" { type = varchar(255) }
primary_key { columns = [column.id] }}Atlas in CI
#!/bin/bashset -e
# Detect the current state, plan migrations, show diffatlas schema diff \ --to file://schema.hcl \ --from "postgres://postgres:$DB_PASSWORD@localhost:5432/myapp?sslmode=disable" \ --format template
# Apply the migrationsatlas schema apply \ --to file://schema.hcl \ --url "postgres://postgres:$DB_PASSWORD@localhost:5432/myapp?sslmode=disable"
echo "Atlas applied schema changes"Pros
- No boilerplate. Declare the schema you want; Atlas does the rest.
- Single binary. No JVM, no Python, no runtimes. Download and run.
- Schema diffs are explicit. You see exactly what’s changing before you apply it.
- Better for large refactors. Renaming a column? Atlas handles it in one step instead of “drop, add, migrate data” tedium.
- Cloud-native. Plays well with Docker, K8s, CI pipelines. Built for 2026.
Cons
- Newer ecosystem. Fewer StackOverflow posts, fewer tutorials.
- Opinionated. Atlas makes certain assumptions about schema design. Less flexible than raw SQL.
- Less mature. One major version bump away from breaking changes. Still in active dev.
- Team adoption. If your team knows Flyway or Liquibase, there’s a retrain cost.
Which One for Your Home Lab?
Pick Flyway if:
- You’re running a single service.
- Your schema changes are simple (add a column, create an index).
- You want SQL and nothing else.
- You’re writing migrations once every few months.
Pick Liquibase if:
- You’re running multiple services against the same database.
- You need rollback safety and drift detection.
- You’re comfortable with YAML/XML overhead.
- You’re in a regulated environment (it has compliance audit trails).
Pick Atlas if:
- You’re designing a new system and want schema as code.
- You hate boilerplate and want the tool to figure out the details.
- You’re in a Dockerized/K8s stack and want a single binary.
- You don’t mind bleeding-edge and occasional surprises.
The Postgres Workflow: Testing Migrations Locally
Whichever tool you pick, here’s the pattern that’ll save your bacon:
#!/bin/bash# Test migration against a local Postgres container
# Spin up a test DBdocker run --rm -d \ --name test-postgres \ -e POSTGRES_PASSWORD=test \ -p 5433:5432 \ postgres:16
sleep 3 # Wait for startup
# Backup production schema (for test data)pg_dump -U postgres -h production-db.local myapp_schema > /tmp/schema_backup.sql
# Restore into test DBpsql -U postgres -h localhost -p 5433 -f /tmp/schema_backup.sql
# Run migrations (Flyway example)flyway -url=jdbc:postgresql://localhost:5433/myapp \ -user=postgres \ -password=test \ -locations=filesystem:./migrations \ migrate
# Test that the app still works with new schemanpm run test:integration -- --db-url=postgresql://postgres:test@localhost:5433/myapp
# If tests pass, you're safe. Clean up:docker stop test-postgresThis workflow, test locally first, then CI, then production, is non-negotiable. You’ll catch 95% of “oops I broke something” moments before they hit production.
The Real Talk
All three tools work. Flyway is the tortoise, Liquibase is the Swiss Army knife, Atlas is the race car. Your choice depends on:
- Team familiarity. What does your team already know?
- Stack complexity. Single app, or multi-service mess?
- Risk tolerance. Want simple and predictable, or smart and automated?
Start with Flyway if you’re unsure. It’s the safest bet. Graduate to Liquibase if you need enterprise features. Jump to Atlas if you’re building something new and want to avoid migration pain entirely.
And seriously: test every migration locally first. No exceptions. Your 2 AM self will thank you.