Part 22 of 24

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:

FlagPurpose
--changelog-fileOutput path and format (extension determines format)
--exclude-objectsExclude Liquibase’s own tracking tables
--diff-typesLimit to specific object types (tables, views, indexes, etc.)
--include-schemaInclude schema name in generated SQL
--schemasRestrict 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 changelogSync in staging
  • Verify liquibase status reports 0 pending changesets in staging
  • Tag staging: liquibase tag v1.0-baseline

Cutover day:

  • Run changelogSync in production (during maintenance window if possible)
  • Verify DATABASECHANGELOG rows 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 diff weekly 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

  1. Always generate from production — that’s the authoritative schema state
  2. Preview with changelogSyncSQL before running changelogSync — on production, see the SQL first
  3. Add preconditions with onFail: MARK_RAN — makes the baseline safe to run against any state
  4. Tag immediately after syncv1.0-baseline is your rollback point
  5. Set ddl-auto: none before enabling Liquibase — never let two tools manage the schema
  6. Align all environments before adoption — schema drift makes the baseline unreliable
  7. Announce and train — the technical cutover is easy; the team habit change is the hard part