Part 12 of 18

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 pair
  • dbms — only define this property for the specified database engine
  • context — only define this property when the context is active
  • global — whether this property applies to all included changelogs (default true)

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):

  1. Passed directly to the Liquibase runner (programmatic API)
  2. JVM system property (-Dschema.name=...)
  3. Environment variable (LIQUIBASE_CHANGELOG_SCHEMA_NAME)
  4. CLI -D flag
  5. liquibase.properties file
  6. Inline property tag in the changelog
  7. defaultValue attribute 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 sql change type
  • Index names, constraint names

Cannot parameterize:

  • Change type names themselves (${change.type}: createTable doesn’t work)
  • onFail / onError values in preconditions
  • Boolean flags like autoIncrement: ${auto.increment}
  • The type attribute in structured change types (use sql changeset 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.parameters in Spring Boot is the cleanest approach — per-environment values in per-environment profile YAML files
  • Always set defaultValue for optional parameters so the changelog works without explicit configuration
  • Use updateSQL to verify substitution before running against any shared environment
  • Stick to lowercase dot-notation for property names (app.schema, not AppSchema or APP_SCHEMA)
  • Do not substitute secrets — passwords and keys belong in connection config, not changelog content
  • For database-engine-specific DDL, prefer dbms preconditions 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 -D flags, environment variables, or spring.liquibase.parameters
  • Priority order: programmatic > JVM system property > env var > CLI > properties file > inline property > defaultValue
  • defaultValue provides safe fallbacks so changelogs work without explicit per-environment config
  • Spring Boot spring.liquibase.parameters map is the cleanest per-environment property source
  • updateSQL is 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 dbms preconditions 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.