Preconditions: Guard Your Migrations with tableExists, sqlCheck, and More
A migration assumes the database is in a specific state. It assumes the table it references exists, the column it modifies is the right type, the user running it has the right privileges, and the database is the right engine. When those assumptions are violated — a hotfix was applied manually, a migration ran out of order, someone ran the wrong changelog against the wrong database — the migration fails in a way that can be hard to diagnose.
Preconditions let you make those assumptions explicit. Instead of a migration silently failing with a cryptic SQL error, you get a clear precondition failure that tells you exactly what was expected and what was found.
What Preconditions Do
A precondition is a check that runs before a changeset’s changes are applied. Based on whether the check passes or fails, Liquibase can:
HALT— stop execution entirely (default foronError)CONTINUE— skip this changeset and continue with the nextMARK_RAN— skip the changeset but record it inDATABASECHANGELOGas if it ranWARN— log a warning and apply the changeset anyway
Preconditions can be placed at two levels:
- Changelog level — checked before any changeset in the file runs
- Changeset level — checked before that specific changeset runs
Precondition Syntax
- changeSet:
id: "20260528-001"
author: abhay
preConditions:
onFail: MARK_RAN
onError: HALT
<precondition-type>: ...
changes:
- ...
The onFail and onError attributes are separate:
onFail— what to do if the precondition check evaluates to falseonError— what to do if the precondition check throws an exception (e.g., SQL error)
Precondition Types
tableExists
Checks whether a table exists in the database. The most common precondition.
- changeSet:
id: "20260528-001"
author: abhay
comment: Add index on users.email — skip if table doesn't exist yet
preConditions:
onFail: MARK_RAN
tableExists:
tableName: users
changes:
- createIndex:
indexName: idx_users_email
tableName: users
columns:
- column: {name: email}
rollback:
- dropIndex:
indexName: idx_users_email
tableName: users
onFail: MARK_RAN means: if users doesn’t exist, record this changeset as done and move on. This is the pattern used when onboarding Liquibase on an existing database — some changesets may describe state that already exists, and you want to skip them rather than error.
columnExists
Checks whether a specific column exists in a table:
- changeSet:
id: "20260529-001"
author: abhay
comment: Add NOT NULL constraint on email_verified — only if column exists
preConditions:
onFail: MARK_RAN
columnExists:
tableName: users
columnName: email_verified
changes:
- addNotNullConstraint:
tableName: users
columnName: email_verified
columnDataType: TINYINT(1)
defaultNullValue: "0"
rollback:
- dropNotNullConstraint:
tableName: users
columnName: email_verified
columnDataType: TINYINT(1)
indexExists
Checks whether an index exists:
- changeSet:
id: "20260530-001"
author: abhay
comment: Drop old non-composite index before creating composite replacement
preConditions:
onFail: MARK_RAN
indexExists:
indexName: idx_users_email_old
tableName: users
changes:
- dropIndex:
indexName: idx_users_email_old
tableName: users
rollback:
- createIndex:
indexName: idx_users_email_old
tableName: users
columns:
- column: {name: email}
foreignKeyConstraintExists
Checks whether a named foreign key constraint exists:
preConditions:
onFail: MARK_RAN
foreignKeyConstraintExists:
foreignKeyName: fk_orders_user_id
foreignKeyTableName: orders
primaryKeyExists
preConditions:
onFail: HALT
primaryKeyExists:
primaryKeyName: PRIMARY
tableName: users
sqlCheck
The most flexible precondition — runs a SQL query and compares the result against an expected value:
- changeSet:
id: "20260531-001"
author: abhay
comment: Migrate suspended users to banned — only if any suspended users exist
preConditions:
onFail: MARK_RAN
sqlCheck:
expectedResult: "0"
sql: SELECT COUNT(*) FROM users WHERE status = 'suspended'
changes:
- update:
tableName: users
columns:
- column: {name: status, value: banned}
where: status = 'suspended'
rollback:
- update:
tableName: users
columns:
- column: {name: status, value: suspended}
where: status = 'banned'
Wait — this precondition says expectedResult: "0" and the intent is to migrate suspended users. If COUNT(*) = 0 matches, onFail: MARK_RAN skips. We want to skip when there are no suspended users. This is correct: the check succeeds (no failure) and the changeset runs when the count is non-zero. When the count is zero, the check fails (expectedResult doesn’t match actual), triggering MARK_RAN.
sqlCheck is powerful because it can express any condition MySQL can answer:
# Ensure the migration user has the right privilege
preConditions:
onFail: HALT
sqlCheck:
expectedResult: "YES"
sql: >
SELECT PRIVILEGE_TYPE
FROM information_schema.USER_PRIVILEGES
WHERE GRANTEE = CONCAT("'", CURRENT_USER(), "'")
AND PRIVILEGE_TYPE = 'CREATE'
LIMIT 1
# Ensure the table has no NULL values before adding NOT NULL constraint
preConditions:
onFail: HALT
sqlCheck:
expectedResult: "0"
sql: SELECT COUNT(*) FROM users WHERE email_verified IS NULL
dbms
Restricts a changeset to run only on specific database engines. Essential when a changelog is shared across MySQL and H2 (common in Spring Boot test configurations):
- changeSet:
id: "20260601-001"
author: abhay
comment: MySQL-specific FULLTEXT index — skipped on H2
preConditions:
onFail: MARK_RAN
dbms:
type: mysql
changes:
- sql:
sql: >
ALTER TABLE products
ADD FULLTEXT INDEX ft_products_name (name, description);
rollback:
- sql:
sql: ALTER TABLE products DROP INDEX ft_products_name;
Without the dbms precondition, this changeset would fail on H2 in your unit tests because H2 does not support FULLTEXT indexes. With onFail: MARK_RAN, H2 skips it and records it as applied.
This is the standard pattern for MySQL-specific features in a project that tests against H2.
runningAs
Checks that the database user executing the changeset matches an expected username:
# Changelog-level precondition — checked before any changeset runs
databaseChangeLog:
preConditions:
onFail: HALT
onError: HALT
runningAs:
username: lb_migration_user
- changeSet:
...
runningAs at the changelog level ensures that the migration user (which has DDL privileges) is being used, not the application user. If someone accidentally points liquibase.properties at the wrong credentials, the entire changelog fails immediately with a clear error rather than failing mid-migration with a permissions error.
Logical Operators: and, or, not
Multiple preconditions can be combined. The default when no operator is specified is and:
preConditions:
onFail: MARK_RAN
and:
- tableExists:
tableName: users
- not:
- columnExists:
tableName: users
columnName: phone
This reads: “proceed only if the users table exists AND the phone column does NOT yet exist.” This is the pattern for idempotent column additions — safe to re-run even if a previous partial deployment created the column.
preConditions:
onFail: HALT
or:
- dbms:
type: mysql
- dbms:
type: mariadb
Changelog-Level Preconditions
Place preconditions at the changelog root (before any changeSet) to gate the entire file:
# db/changelog/migrations/2026/06/20260601-payments-schema.yaml
databaseChangeLog:
preConditions:
onFail: HALT
onError: HALT
onFailMessage: "The orders table must exist before payments can be added."
tableExists:
tableName: orders
- changeSet:
id: "20260601-001"
...
- changeSet:
id: "20260601-002"
...
If orders doesn’t exist, the entire file is blocked with the custom onFailMessage. This is cleaner than putting the same precondition on every changeset in the file when they all share the same dependency.
The Onboarding Pattern: MARK_RAN
The canonical use for MARK_RAN is adopting Liquibase on an existing database. The database has tables that match your changelog, but DATABASECHANGELOG is empty. Running update would attempt to create tables that already exist and fail.
There are two solutions:
changelogSync— marks all changesets as applied without running them (covered in Article 5)MARK_RANpreconditions — changesets check whether their work is already done and skip gracefully
The precondition approach is more robust because it works on a per-changeset basis and continues to work correctly as the database evolves:
- changeSet:
id: "20260602-001"
author: abhay
comment: Create users table — MARK_RAN if already exists (onboarding)
preConditions:
onFail: MARK_RAN
not:
- tableExists:
tableName: users
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: BIGINT UNSIGNED
autoIncrement: true
constraints:
primaryKey: true
nullable: false
# ... rest of columns
rollback:
- dropTable:
tableName: users
This reads: “create the users table, but if it already exists, mark this changeset as ran and continue.” New databases get the table created. Existing databases skip the changeset.
onFail vs onError Decision Guide
| Situation | onFail | onError |
|---|---|---|
| Table might already exist (onboarding) | MARK_RAN | HALT |
| Optional migration (nice to have) | CONTINUE | WARN |
| Safety check (must pass or stop) | HALT | HALT |
| MySQL-specific on mixed DB environment | MARK_RAN | MARK_RAN |
| Data check before constraint (must have clean data) | HALT | HALT |
CONTINUE vs MARK_RAN:
CONTINUE— skip this changeset and do not record it inDATABASECHANGELOG. On the next run, Liquibase will try the precondition again.MARK_RAN— skip this changeset but record it as applied. On the next run, Liquibase will not try it again.
Use MARK_RAN when the precondition condition is permanent (the table will always already exist on this database). Use CONTINUE when the condition is transient and should be retried (a data dependency that might be satisfied later).
Custom Error Messages
Both onFailMessage and onErrorMessage let you write a human-readable explanation that appears in the error output:
preConditions:
onFail: HALT
onFailMessage: >
The payments table cannot be created because the orders table is missing.
Ensure the orders migration (20260514-001) was applied before running this changelog.
tableExists:
tableName: orders
This is the difference between:
ERROR: Precondition failed
and:
ERROR: The payments table cannot be created because the orders table is missing.
Ensure the orders migration (20260514-001) was applied before running this changelog.
Always write onFailMessage for onFail: HALT preconditions in production changelogs.
Common Mistakes
Using MARK_RAN as a default for all preconditions: MARK_RAN silently marks a changeset as applied even though its changes did not run. If the precondition was wrong (you expected the table not to exist but it actually does exist with a different schema), the changeset is permanently skipped. Use MARK_RAN only for onboarding and database-type filtering, where silent skipping is genuinely the right behaviour.
Using sqlCheck with a MySQL-specific query on a mixed environment: A sqlCheck query that uses information_schema or MySQL functions will fail on H2 with an error. Wrap it in a dbms: mysql precondition or use onError: MARK_RAN to handle H2 gracefully.
Missing onFailMessage on HALT preconditions: When a precondition fails and halts execution, the default error message (Precondition failed) gives no context. Anyone running the migration — including a CI system or a new team member — has no idea what to fix. Always write a human-readable message.
Best Practices
- Use
tableExists/columnExistswithMARK_RANfor idempotent migrations during database onboarding - Use
dbms: mysqlwithMARK_RANfor MySQL-specific changesets in projects that also test against H2 - Use
sqlCheckwithHALTto verify data preconditions before applying constraints — cleaner than letting theALTER TABLEfail with a cryptic error - Always write
onFailMessageonHALTpreconditions — make the failure self-diagnosing - Prefer changelog-level preconditions for file-wide dependencies (e.g., “orders table must exist before payments.yaml runs”)
CONTINUEfor transient conditions,MARK_RANfor permanent ones — the distinction determines whether the changeset will be retried on the next run
What You’ve Learned
- Preconditions run before a changeset’s changes and determine whether to proceed, skip, warn, or halt
onFailhandles false precondition checks;onErrorhandles execution errors — set them independentlytableExists,columnExists,indexExists,foreignKeyConstraintExistscheck schema statesqlCheckruns arbitrary MySQL queries — the most flexible precondition typedbmsrestricts changesets to specific database engines — essential for H2/MySQL mixed environmentsrunningAsensures the right database user is executing migrations — a production safety guardMARK_RANis for permanent conditions (already exists);CONTINUEis for transient ones (retry later)- Changelog-level preconditions gate an entire file — use for file-wide dependencies
Next: Article 12 — Property Substitution: Environment-Specific Values in Changelogs — using ${property} placeholders to write changelogs that adapt to each environment without duplicating changesets.