Spring Boot
Spring Boot Liquibase Migration: Changelogs, Changesets, Rollback, Contexts and Testing
Learn Liquibase with Spring Boot: setup, YAML and SQL changesets, preconditions, contexts, rollback, checksum errors, and Testcontainers tests with examples.
What Is Liquibase and How Does It Differ from Flyway?
Liquibase is a database migration tool. You describe each schema change as a changeset (in YAML, XML, JSON or SQL), and Liquibase applies the changesets that have not run yet. Each executed changeset is recorded in the DATABASECHANGELOG table, identified by its id, author and file path, together with a checksum of its content. That record is how Liquibase runs every changeset exactly once and detects when someone edits one that was already applied.
Spring Boot auto-configures Liquibase when liquibase-core is on the classpath and a DataSource exists. It runs the pending changesets at startup, before JPA initialises, so Hibernate sees the finished schema.
Liquibase and Flyway solve the same problem. Flyway is simpler and SQL-first. Liquibase adds database-independent change types, preconditions, contexts and labels, and generated rollback for many change types. Choose one per project; running both against the same schema causes conflicts.
Text
Liquibase Flyway
--------------------- --------------------------------- ---------------------------------
Change format YAML, XML, JSON, formatted SQL Plain SQL (and Java migrations)
Database-neutral DDL Yes (createTable, addColumn ...) No, you write vendor SQL
Preconditions Built in Not built in (callbacks, SQL logic)
Environment targeting Contexts and labels Locations, profiles
Automatic rollback For many change types Paid (undo migrations)
Tracking table DATABASECHANGELOG (+ LOCK table) flyway_schema_history
Learning curve Steeper GentlerAdd the Liquibase Dependency
Add liquibase-core without a version, because Spring Boot manages it. With a configured DataSource and the default changelog location, nothing else is required: Boot finds classpath:db/changelog/db.changelog-master.yaml and runs it on startup.
If you use Spring Data JPA, keep Hibernate from creating or altering tables by setting ddl-auto to validate, so Liquibase is the only owner of the schema.
XML
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
<!-- Gradle: implementation 'org.liquibase:liquibase-core' -->Configure Liquibase in application.yml
Most properties have sensible defaults, so keep configuration small. The settings worth knowing are the changelog path, contexts, and a separate migration user.
Run migrations with a dedicated database user that has DDL rights (spring.liquibase.user and password), while the application itself connects with a least-privilege user. If no Liquibase-specific URL or user is set, Liquibase reuses the main DataSource.
Never set drop-first to true outside a throwaway database: it drops every object in the schema before migrating. Be careful with contexts too. If you set no active contexts at all, Liquibase runs every changeset, including those marked for dev or test only.
yaml
spring:
liquibase:
enabled: true
change-log: classpath:db/changelog/db.changelog-master.yaml # this is the default
contexts: ${LIQUIBASE_CONTEXTS:production} # always set explicitly per environment
label-filter: ${LIQUIBASE_LABELS:}
drop-first: false # NEVER true in production
# Optional: migrate with a privileged user, run the app with a restricted one
# url: jdbc:postgresql://db:5432/app
# user: ${MIGRATION_DB_USER}
# password: ${MIGRATION_DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # Liquibase owns the schema; Hibernate only checks it
# application-test.yml
# spring:
# liquibase:
# contexts: testMaster Changelog: include and includeAll
The master changelog is the entry point. It pulls in other changelog files with include (explicit order) or includeAll (everything in a folder, sorted alphabetically). Zero-padded or date-prefixed file names keep alphabetical order equal to the intended order.
Prefer explicit include for important migrations, because it makes the order visible in code review. If you use includeAll, remember that it also picks up files in sub-folders, so a seed-data folder will run in production unless every changeset in it has a context.
yaml
# src/main/resources/db/changelog/db.changelog-master.yaml
databaseChangeLog:
- include:
file: db/changelog/migrations/001-create-users.yaml
- include:
file: db/changelog/migrations/002-create-products.yaml
- include:
file: db/changelog/migrations/003-create-orders.yaml
- include:
file: db/changelog/migrations/004-add-audit-columns.yaml
# Alternative: everything in a folder, alphabetical order
# - includeAll:
# path: db/changelog/migrations/
# XML master changelog (same idea):
# <databaseChangeLog
# xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
# xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
# xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
# http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
# <include file="db/changelog/migrations/001-create-users.xml"/>
# </databaseChangeLog>Recommended Changelog Folder Layout
Keep one logical change per file, numbered in order, and keep seed data separate and clearly tagged with a context. A flat, numbered structure is easier to review than deeply nested folders.
Text
src/main/resources/
āāā db/
āāā changelog/
āāā db.changelog-master.yaml <- entry point
āāā migrations/
āāā 001-create-users.yaml
āāā 002-create-products.yaml
āāā 003-create-orders.yaml
āāā 004-add-audit-columns.yaml
āāā 005-seed-test-data.yaml <- every changeset has context: "dev or test"Write a YAML Changeset
A changeset is identified by id, author and the file it lives in, so the combination must be unique. Use ids that describe the change. Keep each changeset small and focused on one change.
That last point matters: Liquibase wraps each changeset in a transaction only where the database supports transactional DDL. PostgreSQL does. MySQL and Oracle commit DDL automatically, so a changeset with several changes can fail halfway and leave the schema half-applied. One change per changeset avoids that.
Use portable types such as TIMESTAMP WITH TIME ZONE and CURRENT_TIMESTAMP instead of vendor names like TIMESTAMPTZ and NOW(). A unique constraint already creates an index, so do not add a second index on the same column.
Rollback for createTable, createIndex, addColumn and renameColumn is generated automatically; you only write rollback by hand for changes Liquibase cannot reverse, such as dropTable or raw SQL.
yaml
# 001-create-users.yaml
databaseChangeLog:
- changeSet:
id: 001-create-users-table
author: alice
comment: Create users table
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: VARCHAR(100)
constraints:
nullable: false
- column:
name: email
type: VARCHAR(255)
constraints:
nullable: false
unique: true
uniqueConstraintName: uq_users_email # also gives you an index on email
- column:
name: status
type: VARCHAR(20)
defaultValue: ACTIVE
constraints:
nullable: false
- column:
name: created_at
type: TIMESTAMP WITH TIME ZONE
defaultValueComputed: CURRENT_TIMESTAMP
constraints:
nullable: false
- column:
name: version
type: BIGINT
defaultValueNumeric: 0
constraints:
nullable: false
# One change per changeset
- changeSet:
id: 001-create-users-created-at-index
author: alice
changes:
- createIndex:
indexName: idx_users_created_at
tableName: users
columns:
- column:
name: created_at
descending: true
# rollback (dropIndex) is generated automaticallyForeign Keys, Views and Raw SQL Changesets
Use the built-in change types for tables and constraints, and raw sql for things Liquibase cannot express: views, functions, triggers and vendor features. Put dbms on a changeset when the SQL only works on one database.
Set splitStatements to false for blocks that contain semicolons inside the statement, such as PostgreSQL functions using dollar quoting. Raw SQL cannot be rolled back automatically, so every sql change needs an explicit rollback.
For views and functions that you edit over time, use runOnChange so Liquibase re-runs the changeset when its content changes. This is the one safe exception to the "never edit an applied changeset" rule, as the next section explains.
yaml
# 003-create-orders.yaml
databaseChangeLog:
- changeSet:
id: 003-create-orders-table
author: bob
changes:
- createTable:
tableName: orders
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: user_id
type: BIGINT
constraints:
nullable: false
foreignKeyName: fk_orders_user
references: users(id)
- column:
name: reference
type: VARCHAR(30)
constraints:
nullable: false
unique: true
uniqueConstraintName: uq_orders_reference
- column:
name: total
type: NUMERIC(10,2)
constraints:
nullable: false
- changeSet:
id: 003-order-summary-view
author: bob
runOnChange: true # re-apply when this SQL is edited
changes:
- sql:
splitStatements: false
sql: |
CREATE OR REPLACE VIEW order_summary AS
SELECT o.id AS order_id, o.reference, o.total, u.name AS customer_name
FROM orders o
JOIN users u ON u.id = o.user_id;
rollback:
- sql:
sql: DROP VIEW IF EXISTS order_summary;
- changeSet:
id: 003-recalculate-order-total-function
author: bob
dbms: postgresql # PL/pgSQL only works on PostgreSQL
changes:
- sql:
splitStatements: false
sql: |
CREATE OR REPLACE FUNCTION recalculate_order_total(p_order_id BIGINT)
RETURNS NUMERIC AS $$
DECLARE v_total NUMERIC(10,2);
BEGIN
SELECT COALESCE(SUM(line_total), 0) INTO v_total
FROM order_items WHERE order_id = p_order_id;
UPDATE orders SET total = v_total WHERE id = p_order_id;
RETURN v_total;
END;
$$ LANGUAGE plpgsql;
rollback:
- sql:
sql: DROP FUNCTION IF EXISTS recalculate_order_total(BIGINT);Formatted SQL Changelogs
If your team prefers plain SQL, Liquibase supports formatted SQL: ordinary .sql files with special comments that define the changeset and its rollback. You get Liquibase tracking, contexts and rollback without learning the YAML change types. The first line must be the formatted SQL header, otherwise Liquibase treats the file as a plain script.
Include the file from the master changelog like any other (file: db/changelog/migrations/010-add-brands.sql).
sql
--liquibase formatted sql
--changeset alice:010-create-brands-table
CREATE TABLE brands (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE
);
--rollback DROP TABLE brands;
--changeset alice:011-seed-brands context:dev
INSERT INTO brands (name) VALUES ('Acme'), ('Globex');
--rollback DELETE FROM brands WHERE name IN ('Acme', 'Globex');Never Edit an Applied Changeset
Liquibase stores a checksum for every executed changeset. If you change a changeset that already ran anywhere, the next startup fails with "Validation Failed: 1 changesets check sum". This protects you from environments silently drifting apart.
The rule: once a changeset has run in a shared environment, treat it as immutable and add a new changeset for the correction. During local development, before the changeset was shared, you can drop your local database or clear the checksums. Use validCheckSum only for deliberate, reviewed exceptions, and runOnChange only for objects that are safe to recreate (views, functions, stored procedures).
Also be careful with moving or renaming files. The file path is part of a changeset's identity, so renaming a changelog file makes Liquibase think all its changesets are new. Set logicalFilePath if you must move one.
yaml
# WRONG: editing 001-create-users-table after it was applied -> checksum error
# RIGHT: add a new changeset that makes the correction
databaseChangeLog:
- changeSet:
id: 012-add-users-phone-column
author: carol
changes:
- addColumn:
tableName: users
columns:
- column:
name: phone
type: VARCHAR(30)
# Deliberate, reviewed exception for harmless edits (e.g. a comment):
# - changeSet:
# id: 001-create-users-table
# author: alice
# validCheckSum: ANY # or the specific old checksum from DATABASECHANGELOGPreconditions
Preconditions check the database before a changeset runs and decide what happens on failure. In YAML, the failure behaviour (onFail, onError) and the conditions are separate list items under preConditions.
onFail: MARK_RAN records the changeset as executed without running it, and Liquibase never tries it again. That is right for adopting an existing database but wrong if the condition might become true later. Use onFail: HALT (stop and fail) when a missing prerequisite is a real problem, and CONTINUE to skip now and retry on the next run.
Prefer idempotent design (CREATE ... IF NOT EXISTS) where possible, and use preconditions for safety checks rather than to hide drift.
yaml
databaseChangeLog:
# Add a column only if it is not already there
- changeSet:
id: 013-add-avatar-url-column
author: carol
preConditions:
- onFail: MARK_RAN
- not:
- columnExists:
tableName: users
columnName: avatar_url
changes:
- addColumn:
tableName: users
columns:
- column:
name: avatar_url
type: VARCHAR(500)
# Only on PostgreSQL
- changeSet:
id: 014-postgres-fulltext-index
author: carol
runInTransaction: false # CREATE INDEX CONCURRENTLY cannot run in a transaction
preConditions:
- onFail: MARK_RAN
- dbms:
type: postgresql
changes:
- sql:
splitStatements: false
sql: |
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_name_fts
ON products USING gin (to_tsvector('english', name));
rollback:
- sql:
sql: DROP INDEX IF EXISTS idx_products_name_fts;
# Insert reference data only if it is missing
- changeSet:
id: 015-seed-role-admin
author: carol
preConditions:
- onFail: MARK_RAN
- sqlCheck:
expectedResult: 0
sql: SELECT COUNT(*) FROM roles WHERE name = 'ADMIN'
changes:
- insert:
tableName: roles
columns:
- column:
name: name
value: ADMINContexts and Labels
Contexts and labels decide which changesets run in which environment. A context is set on the changeset (the context attribute) and selected at runtime with spring.liquibase.contexts. Labels work the same way for feature or release targeting, selected with spring.liquibase.label-filter.
Three rules cause most surprises. A changeset with no context runs in every environment. When no active context is configured, all changesets run regardless of their context. And a comma-separated list means "any of these", while expressions support and, or and ! for combinations.
For production safety, set contexts explicitly in every environment and keep test data in changesets that are marked context: "dev or test".
yaml
databaseChangeLog:
# No context: runs everywhere
- changeSet:
id: 020-create-categories
author: alice
changes:
- createTable:
tableName: categories
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
# Dev and test only
- changeSet:
id: 021-seed-test-users
author: alice
context: "dev or test"
changes:
- insert:
tableName: users
columns:
- column: {name: name, value: "Test User"}
- column: {name: email, value: "test@example.com"}
# Production only
- changeSet:
id: 022-production-orders-index
author: bob
context: production
changes:
- createIndex:
indexName: idx_orders_reference
tableName: orders
columns:
- column:
name: reference
# Labels for release or feature targeting
- changeSet:
id: 023-add-recommendations-table
author: carol
labels: "feature-recommendations,release-2.0"
changes:
- createTable:
tableName: recommendations
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
# Select at runtime:
# spring.liquibase.contexts: production
# spring.liquibase.label-filter: feature-recommendationsRollback: Automatic, Explicit and Irreversible Changes
Liquibase can generate rollback for many built-in change types: createTable becomes dropTable, addColumn becomes dropColumn, renameColumn is swapped back. It cannot reverse dropTable, dropColumn, raw sql, or data changes, so those need an explicit rollback block.
Some changes cannot be reversed at all. Normalising data in place loses the original values. Document that in a comment, and use an empty rollback (the <rollback/> element in XML) or a safe no-op so a rollback command does not fail, knowing it will not undo the data.
Always test rollback before you need it: run update, rollback and update again in a disposable database, ideally in your build.
yaml
databaseChangeLog:
# Automatic rollback is enough: addColumn is reversed by dropColumn
- changeSet:
id: 030-add-product-weight
author: dave
changes:
- addColumn:
tableName: products
columns:
- column:
name: weight_kg
type: NUMERIC(8,3)
# Destructive change: the rollback must recreate what was dropped
- changeSet:
id: 031-drop-legacy-imports
author: dave
changes:
- dropTable:
tableName: legacy_imports
rollback:
- createTable:
tableName: legacy_imports
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
- column:
name: data
type: TEXT
# Note: this recreates the structure only; the dropped rows are gone.
# Raw SQL: always write the rollback yourself
- changeSet:
id: 032-add-check-constraint
author: dave
changes:
- sql:
sql: ALTER TABLE products ADD CONSTRAINT chk_price_positive CHECK (price >= 0);
rollback:
- sql:
sql: ALTER TABLE products DROP CONSTRAINT chk_price_positive;Run Rollback and Other Liquibase Commands
Spring Boot runs update automatically at startup, but rollback and the other maintenance commands are run explicitly with the Liquibase CLI, the Liquibase Maven plugin, or the Gradle plugin. Rolling back automatically on application startup is not supported by Spring Boot's integration.
Command names differ slightly between Liquibase versions (older versions use camelCase such as rollbackCount), so check the reference for your version. Run rollback in a staging database first, and take a backup before using it in production.
Spring Boot Actuator also exposes the executed changesets through the liquibase endpoint, which is a convenient way to see what ran.
Shell
# Liquibase 4.x CLI (flag names vary slightly by version)
liquibase status --verbose # list changesets not yet applied
liquibase update # apply pending changesets
liquibase tag --tag=release-2.1 # mark the current state
liquibase rollback --tag=release-2.1 # roll back to a tag
liquibase rollback-count --count=1 # roll back the last changeset
liquibase update-sql # preview SQL without running it
liquibase release-locks # clear a stuck lock
liquibase clear-checksums # recompute checksums (use with care)
# Actuator: expose the endpoint, then GET /actuator/liquibase
management:
endpoints:
web:
exposure:
include: health,liquibaseAdding Liquibase to an Existing Database
When a database already has tables, a normal update fails with "table already exists". Create a baseline changelog that describes the current schema, then tell Liquibase that those changesets are already applied without running them. After that, only new changesets run.
Generate the baseline with the generate-changelog command against the existing database, review it carefully (generated output often needs cleanup), then mark it as applied with changelog-sync. For a team with several environments, do the sync once per environment.
Shell
# 1. Generate a baseline from the existing database
liquibase generate-changelog --changelog-file=db/changelog/migrations/000-baseline.yaml
# 2. Review and edit the generated file, then include it first in the master changelog
# 3. Mark the baseline as already applied (does not execute it)
liquibase changelog-sync
# 4. From now on, add new changesets and let Spring Boot run them at startupTest Migrations with Testcontainers
The most valuable Liquibase test is also the simplest: start an empty database, let Spring Boot run every changeset, then assert that the application context starts (which, with ddl-auto: validate, also proves the entities match the schema) and that the tracking table recorded the changesets.
Use the database engine you run in production. Since Spring Boot 3.1, @ServiceConnection connects the container to the datasource without manual properties. Use a context such as test so seed changesets run in tests but not in production.
Java
@SpringBootTest(properties = "spring.liquibase.contexts=test")
@Testcontainers
class LiquibaseMigrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
JdbcTemplate jdbcTemplate;
@Test
void allChangesetsAreApplied() {
Integer applied = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM databasechangelog", Integer.class);
assertThat(applied).isGreaterThan(0);
}
@Test
void usersTableHasExpectedColumns() {
List<String> columns = jdbcTemplate.queryForList(
"SELECT column_name FROM information_schema.columns WHERE table_name = 'users'",
String.class);
assertThat(columns).contains("id", "name", "email", "status", "created_at", "version");
}
@Test
void defaultsAreApplied() {
jdbcTemplate.update("INSERT INTO users (name, email) VALUES (?, ?)", "Alice", "alice@example.com");
String status = jdbcTemplate.queryForObject(
"SELECT status FROM users WHERE email = ?", String.class, "alice@example.com");
assertThat(status).isEqualTo("ACTIVE");
}
}Test That Rollback Works
Test rollback in its own test against a fresh database, not the one your Spring context already migrated. Liquibase provides updateTestingRollback, which applies all changesets, rolls them back, and applies them again. If any rollback block is missing or wrong, the test fails.
This runs without a Spring context, so it is fast to set up. Run it in CI so a broken rollback is caught when the changeset is written, not during an incident.
Java
@Testcontainers
class LiquibaseRollbackTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void updateRollbackUpdateSucceeds() throws Exception {
try (Connection connection = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
Database database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(new JdbcConnection(connection));
try (Liquibase liquibase = new Liquibase(
"db/changelog/db.changelog-master.yaml",
new ClassLoaderResourceAccessor(),
database)) {
// update -> rollback everything -> update again
liquibase.updateTestingRollback(new Contexts("test"), new LabelExpression());
}
}
}
}Common Spring Boot Liquibase Errors and How to Fix Them
Most Liquibase failures come from editing applied changesets, an interrupted previous run, running against a database that already has tables, or environment-specific SQL. The table lists the messages people search for and the usual fix.
Text
Error / symptom Cause Fix
------------------------------------------------------- ---------------------------------------------- --------------------------------------------
Validation Failed: 1 changesets check sum An applied changeset was edited Revert the edit and add a new changeset;
in local dev drop the DB or clear checksums
Waiting for changelog lock.... / Could not acquire A previous run crashed and left the lock set, Make sure no instance is migrating, then run
change log lock or two instances start at once release-locks (or reset DATABASECHANGELOGLOCK)
relation / table "x" already exists Liquibase added to an existing database Create a baseline and run changelog-sync, or
use a tableExists precondition with MARK_RAN
CREATE INDEX CONCURRENTLY cannot run inside a Changeset runs inside a transaction Add runInTransaction: false to the changeset
transaction block
Unknown type TIMESTAMPTZ / NOW() fails on other DB Vendor-specific types and functions Use TIMESTAMP WITH TIME ZONE and CURRENT_TIMESTAMP
Schema-validation: missing table / wrong column type Entity does not match the Liquibase schema Fix the entity or add a changeset; keep
ddl-auto: validate
Seed data appears in production No context set on the changeset or the app Mark seed changesets with context and set
spring.liquibase.contexts everywhere
All changesets re-run after moving a file File path is part of changeset identity Set logicalFilePath or avoid renaming files
Cannot find changelog location: classpath:... Wrong path or file not on the classpath Check spring.liquibase.change-log and the
location under src/main/resourcesLiquibase Production Checklist
Use this list before the first production deployment and whenever a team joins a project. Most Liquibase incidents trace back to a skipped item here: wrong contexts, edited changesets, untested rollback, or migrating with an over-privileged application user.
Text
[ ] liquibase-core is managed by Spring Boot; no hardcoded version
[ ] ddl-auto is validate (or none), never update or create-drop against a real database
[ ] drop-first is false in every environment except a disposable local database
[ ] spring.liquibase.contexts is set explicitly in every environment
[ ] Seed and test data changesets all carry a context such as "dev or test"
[ ] Applied changesets are never edited; fixes are new changesets
[ ] One change per changeset on databases without transactional DDL (MySQL, Oracle)
[ ] Every sql / dropTable / dropColumn changeset has an explicit rollback
[ ] updateTestingRollback runs in CI against the production database engine
[ ] Migrations run with a DDL-capable user; the application uses a restricted user
[ ] A backup exists before every production migration
[ ] Only one application instance migrates at a time, or migration runs as a separate job
[ ] DATABASECHANGELOG is monitored (or the Actuator liquibase endpoint is available)Frequently Asked Questions
How do I use Liquibase with Spring Boot?
Add liquibase-core, create db/changelog/db.changelog-master.yaml, and write changesets. Spring Boot runs pending changesets automatically at startup.
Where does Spring Boot look for the Liquibase changelog?
At classpath:db/changelog/db.changelog-master.yaml by default. Change it with spring.liquibase.change-log.
What does the "Validation Failed: changesets check sum" error mean?
A changeset that was already applied has been edited. Revert it and put the change in a new changeset.
Why is Liquibase waiting for the changelog lock?
A previous migration did not release DATABASECHANGELOGLOCK, usually after a crash. Confirm nothing is migrating, then release the lock.
Can Liquibase roll back automatically at startup?
No. Spring Boot runs update at startup. Run rollback with the CLI or a build plugin.
What is the difference between contexts and labels?
Contexts target environments (dev, test, production). Labels target features or releases. Both filter which changesets run.
Should I use YAML, XML or SQL changelogs?
All work. YAML and XML give database-independent change types; formatted SQL is easiest when your team already writes vendor SQL. You can mix formats in one project.
Can I use Liquibase and Flyway together?
You should not. Pick one tool per schema to avoid conflicting history tables.
Does Liquibase need ddl-auto: validate?
It is strongly recommended. It lets Hibernate verify that the schema Liquibase built matches your entities, and prevents Hibernate from changing the schema itself.