Property Substitution: Environment-Specific Values in Changelogs
A changeset that hard-codes the schema name ecommerce works in production but breaks when your staging database is called ecommerce_staging. A changeset that seeds a specific admin email works in dev but shouldn’t run with the same value in staging. Property substitution lets you parameterize these values so one changelog serves every environment.
How Property Substitution Works
Liquibase replaces ${property-name} tokens in your changelog with the value assigned to that property before executing any SQL. The token is replaced at read time — before Liquibase parses the change type or generates SQL.
# Changelog with a substitution token
- changeSet:
id: "20260603-001"
author: abhay
changes:
- sql:
sql: CREATE SCHEMA IF NOT EXISTS ${schema.name};
If schema.name = ecommerce_staging, Liquibase executes:
CREATE SCHEMA IF NOT EXISTS ecommerce_staging;
If the property is not defined, Liquibase leaves the token as-is and MySQL will fail with a syntax error — which is the correct behaviour, since an undefined property is almost always a configuration mistake.
Defining Properties
Method 1: Inline in the changelog (with optional defaultValue)
databaseChangeLog:
- property:
name: schema.name
value: ecommerce
- property:
name: admin.email
value: admin@local.dev
dbms: mysql # Only set for MySQL connections
- property:
name: max.pool.size
value: "10"
context: prod # Only set when 'prod' context is active
- changeSet:
id: "20260603-001"
author: abhay
changes:
- sql:
sql: >
INSERT INTO config (key, value)
VALUES ('admin.email', '${admin.email}');
The property tag supports:
name/value— the substitution pairdbms— only define this property for the specified database enginecontext— only define this property when the context is activeglobal— whether this property applies to all included changelogs (defaulttrue)
Method 2: liquibase.properties file
# liquibase.properties
changeLogFile=db/changelog/db.changelog-master.yaml
url=jdbc:mysql://localhost:3306/ecommerce
username=lb_user
password=lb_pass
# Custom substitution properties
schema.name=ecommerce
admin.email=admin@local.dev
seed.batch.size=100
Any key in liquibase.properties that is not a known Liquibase parameter is treated as a changelog substitution property.
Method 3: CLI flag
liquibase update \
-Dschema.name=ecommerce_staging \
-Dadmin.email=staging-admin@company.com
The -D prefix (standard Java system property syntax) passes a value that overrides the same property from liquibase.properties or an inline property tag.
Method 4: Environment variables
export LIQUIBASE_CHANGELOG_SCHEMA_NAME=ecommerce_prod
liquibase update
Liquibase maps environment variables to changelog properties by converting the name: uppercase, replace dots with underscores, and prefix with LIQUIBASE_CHANGELOG_. The example above sets schema.name.
Method 5: Spring Boot application.yml
spring:
liquibase:
parameters:
schema.name: ecommerce
admin.email: admin@local.dev
seed.batch.size: "100"
This is the cleanest approach in Spring Boot projects. The parameters map feeds directly into Liquibase’s property substitution system.
Priority Order
When the same property is defined in multiple places, Liquibase resolves by priority (highest first):
- Passed directly to the Liquibase runner (programmatic API)
- JVM system property (
-Dschema.name=...) - Environment variable (
LIQUIBASE_CHANGELOG_SCHEMA_NAME) - CLI
-Dflag liquibase.propertiesfile- Inline
propertytag in the changelog defaultValueattribute on the property tag
This means environment variables override liquibase.properties, which overrides inline changelog properties. Use this to set safe defaults in the changelog and override them per environment.
defaultValue: Safe Fallback for Optional Properties
The defaultValue attribute provides a fallback when a property is not explicitly set:
- property:
name: schema.name
value: ecommerce # This line is optional if defaultValue is set
- property:
name: seed.batch.size
defaultValue: "50"
If seed.batch.size is not set by any higher-priority source, it defaults to 50. This makes optional tuning parameters safe — the changeset works without them being explicitly configured.
Real-World Patterns
Pattern 1: Schema-qualified table names
When your project uses multiple schemas or the schema name differs between environments:
databaseChangeLog:
- property:
name: app.schema
value: ecommerce
- changeSet:
id: "20260604-001"
author: abhay
comment: Create orders table in the application schema
changes:
- sql:
sql: >
CREATE TABLE IF NOT EXISTS ${app.schema}.orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
total DECIMAL(12,2) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
rollback:
- sql:
sql: DROP TABLE IF EXISTS ${app.schema}.orders;
Per-environment Spring Boot config:
# application-dev.yml
spring:
liquibase:
parameters:
app.schema: ecommerce_dev
# application-prod.yml
spring:
liquibase:
parameters:
app.schema: ecommerce
Pattern 2: Environment-specific default values
databaseChangeLog:
- property:
name: session.timeout.minutes
value: "30"
- changeSet:
id: "20260605-001"
author: abhay
comment: Insert default session timeout configuration
changes:
- insert:
tableName: app_config
columns:
- column: {name: key, value: "session.timeout"}
- column: {name: value, value: "${session.timeout.minutes}"}
rollback:
- delete:
tableName: app_config
where: key = 'session.timeout'
Override in production:
# application-prod.yml
spring:
liquibase:
parameters:
session.timeout.minutes: "60"
Pattern 3: Table name prefixes for multi-tenant schemas
When one MySQL instance serves multiple tenants, each with a prefixed set of tables:
databaseChangeLog:
- property:
name: table.prefix
value: ""
defaultValue: ""
- changeSet:
id: "20260606-001"
author: abhay
changes:
- createTable:
tableName: ${table.prefix}users
columns:
- column:
name: id
type: BIGINT UNSIGNED
autoIncrement: true
constraints:
primaryKey: true
With table.prefix=tenant_a_, the table created is tenant_a_users. With no prefix set, it is users.
Pattern 4: MySQL engine and charset per environment
Local dev may use the default InnoDB settings. Production might need explicit row format for large deployments:
- property:
name: mysql.row.format
value: DYNAMIC
defaultValue: DYNAMIC
- changeSet:
id: "20260607-001"
author: abhay
changes:
- sql:
sql: >
CREATE TABLE products (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(500) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB
ROW_FORMAT=${mysql.row.format}
DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_unicode_ci;
rollback:
- dropTable:
tableName: products
Property Substitution in Spring Boot: Full Example
Project structure:
src/main/resources/
├── application.yml
├── application-dev.yml
├── application-staging.yml
├── application-prod.yml
└── db/changelog/
└── db.changelog-master.yaml
db.changelog-master.yaml:
databaseChangeLog:
- property:
name: app.schema
defaultValue: ecommerce
- property:
name: admin.email
defaultValue: admin@localhost
- property:
name: max.varchar.length
defaultValue: "255"
- includeAll:
path: db/changelog/migrations/
relativeToChangelogFile: false
application.yml (base — safe dev defaults):
spring:
liquibase:
parameters:
app.schema: ecommerce
admin.email: admin@local.dev
max.varchar.length: "255"
application-staging.yml:
spring:
liquibase:
parameters:
app.schema: ecommerce_staging
admin.email: staging-admin@company.com
max.varchar.length: "512"
application-prod.yml:
spring:
liquibase:
parameters:
app.schema: ecommerce
admin.email: ${PROD_ADMIN_EMAIL} # Injected from environment
max.varchar.length: "512"
Note the ${PROD_ADMIN_EMAIL} in the YAML — this is a Spring expression (resolved by Spring Boot), not a Liquibase token. Spring resolves it before passing the value to spring.liquibase.parameters, so Liquibase receives the final value.
What Property Substitution Cannot Do
Substitution is text replacement. It runs before Liquibase parses the change type. This means:
Can parameterize:
- Table names, column names, schema names
- Default values (as strings)
- SQL snippets within
sqlchange type - Index names, constraint names
Cannot parameterize:
- Change type names themselves (
${change.type}: createTabledoesn’t work) onFail/onErrorvalues in preconditions- Boolean flags like
autoIncrement: ${auto.increment} - The
typeattribute in structured change types (usesqlchangeset for dynamic DDL)
For cases where the change type itself needs to vary per environment, use the dbms attribute on the changeset or a dbms precondition (Article 11) instead of property substitution.
Debugging Substitution: updateSQL
The clearest way to verify property substitution is working correctly is to run updateSQL and inspect the output:
# With explicit property override
liquibase updateSQL -Dapp.schema=ecommerce_debug
# In Spring Boot, trigger via Maven plugin
./mvnw liquibase:updateSQL -Dliquibase.parameters.app.schema=ecommerce_debug
The output SQL shows the substituted values. If you see ${app.schema} literally in the SQL output, the property was not resolved — check the property name spelling and the priority order.
Common Mistakes
Property name case sensitivity: Property names are case-sensitive in Liquibase. ${schema.name} and ${Schema.Name} are different tokens. Establish a convention (lowercase with dots is standard) and stick to it.
Confusing Spring property expressions with Liquibase tokens: In application-prod.yml, writing admin.email: ${PROD_ADMIN_EMAIL} is a Spring expression that Spring resolves. In a YAML changelog, ${admin.email} is a Liquibase token that Liquibase resolves. They use the same ${} syntax but operate in different systems. Spring expressions in application.yml work at Spring startup; Liquibase tokens in changelogs work when the changelog is parsed.
Using substitution for secrets in changelogs: Property values are logged in updateSQL output and may appear in Liquibase logs. Do not use property substitution for passwords or API keys in changelog SQL. Keep secrets in the connection configuration (liquibase.properties credentials, Spring spring.datasource.*) and out of changelog content.
Best Practices
spring.liquibase.parametersin Spring Boot is the cleanest approach — per-environment values in per-environment profile YAML files- Always set
defaultValuefor optional parameters so the changelog works without explicit configuration - Use
updateSQLto verify substitution before running against any shared environment - Stick to lowercase dot-notation for property names (
app.schema, notAppSchemaorAPP_SCHEMA) - Do not substitute secrets — passwords and keys belong in connection config, not changelog content
- For database-engine-specific DDL, prefer
dbmspreconditions over property-substituted SQL — it is more explicit and self-documenting
What You’ve Learned
${property-name}tokens in changelogs are replaced with values before Liquibase generates SQL- Properties can be defined inline in the changelog, in
liquibase.properties, via CLI-Dflags, environment variables, orspring.liquibase.parameters - Priority order: programmatic > JVM system property > env var > CLI > properties file > inline property > defaultValue
defaultValueprovides safe fallbacks so changelogs work without explicit per-environment config- Spring Boot
spring.liquibase.parametersmap is the cleanest per-environment property source updateSQLis the verification tool — if you see${token}in the output, the property was not resolved- Substitution cannot parameterize change type names or boolean flags — use
dbmspreconditions for engine-specific behaviour
Part 2 complete. You now have a full intermediate toolkit: changelog organization, 12 migration patterns, rollback strategies, context/label filtering, preconditions, and property substitution.
Next: Article 13 — Stored Procedures, Views, and Triggers in MySQL — the first article of Part 3, covering how to manage database objects that require raw SQL and special handling in Liquibase.