Adopting Liquibase on an Existing Production Database
Most teams don’t start with Liquibase. You inherit a production database that’s been running for years, modified by scripts, hotfixes, and well-intentioned developers who “just ran it manually.” Now you want version control. You want repeatable deployments. You want to stop the “did you run the migration?” Slack message.
The good news: you can adopt Liquibase without touching production data. The bad news: it requires care — not complexity, just care. This article walks through the full adoption process: generating a baseline, syncing the tracking tables, coordinating your team, and disabling the Hibernate settings that will fight you if you leave them on.
Why Adoption Is Different from a Greenfield Project
On a greenfield project, Liquibase runs every changeset from the start. On an existing database, the schema already exists — if you run your baseline changelog, Liquibase will try to recreate tables that are already there and fail.
The solution is changelogSync: tell Liquibase “these changesets have already been applied” without actually running them. From that point forward, all new changesets run normally.
The three-phase adoption process:
Phase 1: Generate a baseline changelog that describes current state
Phase 2: Sync the tracking tables so Liquibase believes it created that state
Phase 3: All future changes go through new changesets
Phase 1: Generate the Baseline Changelog
Step 1: Align All Environments First
Before generating anything, make sure dev, staging, and production schemas match. Misaligned schemas are the most common adoption failure point — you generate a baseline from production, but dev has extra columns from local experiments.
Check for differences:
# Compare dev schema to production
liquibase diff \
--url=jdbc:mysql://dev-host:3306/ecommerce \
--username=dev_user \
--password=dev_pass \
--reference-url=jdbc:mysql://prod-host:3306/ecommerce \
--reference-username=prod_user \
--reference-password=prod_pass
Fix divergences manually before proceeding. Every unresolved difference becomes a problem later.
Step 2: Generate the Baseline from Production
liquibase generateChangelog \
--url=jdbc:mysql://prod-host:3306/ecommerce \
--username=liquibase_user \
--password=secret \
--changelog-file=db/changelog/v1.0/1.0-baseline.yaml \
--exclude-objects="DATABASECHANGELOG,DATABASECHANGELOGLOCK"
Key flags:
| Flag | Purpose |
|---|---|
--changelog-file | Output path and format (extension determines format) |
--exclude-objects | Exclude Liquibase’s own tracking tables |
--diff-types | Limit to specific object types (tables, views, indexes, etc.) |
--include-schema | Include schema name in generated SQL |
--schemas | Restrict to specific schemas |
Step 3: Review and Clean the Generated Changelog
generateChangelog is a starting point, not a finished product. Things to check:
Remove sensitive data. If you accidentally ran with --include-data, strip passwords, API keys, and PII before committing.
Check for Liquibase tables. The --exclude-objects flag should handle this, but verify:
grep -i "DATABASECHANGELOG" db/changelog/v1.0/1.0-baseline.yaml
Verify completeness. Count tables in the generated changelog vs. production:
-- In production MySQL
SELECT COUNT(*) FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'ecommerce' AND TABLE_TYPE = 'BASE TABLE';
grep -c "createTable" db/changelog/v1.0/1.0-baseline.yaml
Add preconditions for safety. Wrap table creation changesets with existence checks, so if this baseline ever runs against a schema that already has the tables, it won’t fail:
databaseChangeLog:
- changeSet:
id: 1.0-baseline-users
author: abhay
preConditions:
onFail: MARK_RAN
not:
- tableExists:
tableName: users
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
- column:
name: email
type: VARCHAR(255)
constraints:
nullable: false
unique: true
The onFail: MARK_RAN precondition is your safety net — if the table already exists (which it does on production), the changeset is recorded as applied without executing. This is critical for multi-environment adoption where environments may be in different states.
Step 4: Organize into a Master Changelog
src/main/resources/db/changelog/
├── db.changelog-master.yaml ← includes everything in order
└── v1.0/
└── 1.0-baseline.yaml ← generated baseline
db.changelog-master.yaml:
databaseChangeLog:
- include:
file: db/changelog/v1.0/1.0-baseline.yaml
Phase 2: Sync the Tracking Tables
Now comes the critical step: telling Liquibase that the baseline changesets have already been applied.
Preview First (Always)
Before syncing production, preview what will happen:
liquibase changelogSyncSQL \
--changelog-file=db/changelog/db.changelog-master.yaml \
--url=jdbc:mysql://prod-host:3306/ecommerce \
--username=liquibase_user \
--password=secret
This prints the INSERT statements that will be added to DATABASECHANGELOG — no schema changes, just tracking table records. Review them, share with your DBA.
Run the Sync
During a maintenance window (or any time — changelogSync makes no schema changes):
liquibase changelogSync \
--changelog-file=db/changelog/db.changelog-master.yaml \
--url=jdbc:mysql://prod-host:3306/ecommerce \
--username=liquibase_user \
--password=secret
Verify the Sync
SELECT COUNT(*) FROM DATABASECHANGELOG;
SELECT id, author, filename, dateexecuted, exectype
FROM DATABASECHANGELOG
ORDER BY orderexecuted;
EXECTYPE will be EXECUTED for normal runs and MARK_RAN for changesets skipped by preconditions.
Tag the Baseline
Immediately after syncing, tag the current state:
liquibase tag --tag=v1.0-baseline \
--url=jdbc:mysql://prod-host:3306/ecommerce \
--username=liquibase_user \
--password=secret
This creates a rollback point. If anything goes wrong with the first real changeset, you can roll back to the baseline tag.
Phase 3: Future Changes Through Changesets Only
From this point forward, every schema change must be a changeset. No more direct SQL. No more ALTER TABLE in Slack DMs.
Add new changesets in version-stamped files:
db/changelog/
├── db.changelog-master.yaml
├── v1.0/
│ └── 1.0-baseline.yaml
└── v1.1/
├── 1.1-001-add-user-preferences.yaml
└── 1.1-002-add-product-tags.yaml
Update the master to include v1.1:
databaseChangeLog:
- include:
file: db/changelog/v1.0/1.0-baseline.yaml
- include:
file: db/changelog/v1.1/1.1-001-add-user-preferences.yaml
- include:
file: db/changelog/v1.1/1.1-002-add-product-tags.yaml
Spring Boot: Disabling Hibernate ddl-auto
If you’re using Spring Boot with JPA/Hibernate, you have an urgent configuration change to make before Liquibase can fully take over.
The Problem
Hibernate’s ddl-auto setting automatically creates or updates the schema based on your entity classes. With Liquibase managing the schema, you have two competing tools — and Hibernate wins on startup, often destroying what Liquibase just built.
Worst case: spring.jpa.hibernate.ddl-auto=create-drop will recreate the entire schema on every startup and drop it on shutdown. In production.
The Fix
# application.yaml — applies to all environments
spring:
jpa:
hibernate:
ddl-auto: none # Hibernate touches nothing
open-in-view: false
liquibase:
enabled: true
change-log: classpath:db/changelog/db.changelog-master.yaml
For per-environment application files, set ddl-auto: none in all of them — dev, test, staging, production. Don’t leave it as create in dev; your dev schema should match production schema exactly, and Liquibase ensures that.
Spring Boot Startup Order
Liquibase runs before any Spring beans are initialized. The sequence on startup:
1. DataSource created
2. Liquibase runs (all pending changesets applied)
3. JPA/Hibernate entity scanning (but no DDL because ddl-auto: none)
4. Application beans created
5. Application starts
This means if Liquibase fails, the application never starts — which is exactly what you want. A schema migration failure should block deployment, not be silently swallowed.
Verifying Liquibase Is Running
Add the Actuator endpoint to confirm:
management:
endpoints:
web:
exposure:
include: liquibase
Then hit GET /actuator/liquibase to see all applied changesets. In tests, you can assert:
@SpringBootTest
class LiquibaseMigrationTest {
@Autowired
private DataSource dataSource;
@Test
void baselineMigrationsApplied() throws Exception {
try (var conn = dataSource.getConnection();
var rs = conn.prepareStatement(
"SELECT COUNT(*) FROM DATABASECHANGELOG"
).executeQuery()) {
rs.next();
assertThat(rs.getInt(1)).isGreaterThan(0);
}
}
}
Team Cutover Coordination
Adoption fails when one developer keeps running direct SQL while another is using Liquibase. The technical step is easy — the coordination is harder.
The Cutover Checklist
Two weeks before:
- Announce the adoption date and approach
- Share a one-page guide: “How to create a changeset” with a template
- Align all environment schemas (run
liquibase diff) - Generate and review the baseline changelog
- Test the sync in staging
One week before:
- Merge the baseline changelog and Spring Boot config change to main
- Run
changelogSyncin staging - Verify
liquibase statusreports 0 pending changesets in staging - Tag staging:
liquibase tag v1.0-baseline
Cutover day:
- Run
changelogSyncin production (during maintenance window if possible) - Verify
DATABASECHANGELOGrows in production - Tag production:
liquibase tag v1.0-baseline - Announce: “Direct DDL is now prohibited — all changes must be changesets”
After cutover:
- First real changeset deployed through Liquibase (validates the pipeline end-to-end)
- Enforce via code review: PRs without a changeset for schema changes are rejected
- Run
liquibase diffweekly for the first month to catch schema drift
Changeset Template for New Developers
Give everyone this template:
databaseChangeLog:
- changeSet:
id: "YYYY-MM-DD-NNN-short-description" # e.g., 2026-05-15-001-add-coupon-code
author: your-github-username
comment: "What this does and why (optional)"
changes:
# Your changes here
rollback:
# How to undo this change
Handling the Common Adoption Scenarios
Scenario 1: Some Environments Are Ahead
Dev has columns that production doesn’t. This is the most common problem.
Option A: Add those columns to production manually first, then generate the baseline.
Option B: Generate the baseline from dev, but use preconditions on every changeset:
preConditions:
onFail: MARK_RAN
not:
- columnExists:
tableName: users
columnName: preferences_json
Scenario 2: No Downtime — Prod Is Always Live
You can run changelogSync without downtime — it only writes to the tracking tables, no schema changes. Schedule it whenever the team is not actively deploying, but it doesn’t require a maintenance window.
Scenario 3: Multiple Databases (Multi-Tenant)
Run the sync against each database. If schemas differ between tenants, generate separate baselines:
for DB in tenant1 tenant2 tenant3; do
liquibase changelogSync \
--url="jdbc:mysql://prod-host:3306/$DB" \
--username=liquibase_user \
--password=secret \
--changelog-file="db/changelog/db.changelog-master.yaml"
done
Scenario 4: Views and Stored Procedures
generateChangelog captures views and procedures, but the generated SQL may need adjustment — especially for MySQL stored procedures (delimiter issues, see Article 13).
After generating, verify procedure definitions manually:
SHOW CREATE PROCEDURE procedure_name;
SHOW CREATE VIEW view_name;
Compare against the generated changelog and fix any differences before syncing.
What Can Go Wrong
Checksum mismatch on first real changeset. You edited the baseline changelog after syncing. The checksum recorded during sync no longer matches the file. Fix: liquibase clearCheckSums and re-sync. Rule: never modify a synced changeset.
MARK_RAN masking real failures. If a precondition marks a changeset as ran but the actual object doesn’t exist, downstream changesets (like foreign keys referencing that table) will fail. Fix: after syncing, run liquibase validate and manually verify every MARK_RAN changeset actually exists in the database.
Team members still running direct SQL. Run liquibase diff weekly for the first month. Any differences mean someone bypassed Liquibase. Investigate, add a changeset to capture the change, re-sync.
Spring startup loops. If Liquibase fails on startup, Spring Boot may retry, leading to repeated failures. Fix: fail fast with spring.liquibase.enabled=true and correct configuration — look at startup logs for the exact error.
Golden Rules for Adoption
- Always generate from production — that’s the authoritative schema state
- Preview with changelogSyncSQL before running changelogSync — on production, see the SQL first
- Add preconditions with
onFail: MARK_RAN— makes the baseline safe to run against any state - Tag immediately after sync —
v1.0-baselineis your rollback point - Set
ddl-auto: nonebefore enabling Liquibase — never let two tools manage the schema - Align all environments before adoption — schema drift makes the baseline unreliable
- Announce and train — the technical cutover is easy; the team habit change is the hard part