Part 10 of 18

Contexts and Labels: Multi-Environment Filtering

The most common multi-environment problem in database migrations: seed data that should run in dev and staging but must never touch production. The naive solution is maintaining separate changelogs per environment. The correct solution is contexts and labels — Liquibase’s built-in filtering mechanism that lets a single changelog serve every environment.

This article covers both features, explains the critical difference between them, and shows exactly how to wire them into Spring Boot profiles.


The Problem They Solve

Without filtering, every changeset in the changelog runs in every environment. That is fine for schema changes — you want the same tables everywhere. It is a problem for:

  • Seed data: Admin users, product categories, country lists — you want them in dev but not in prod (prod data comes from real users)
  • Test data: Large synthetic datasets for load testing that would pollute a staging database
  • Environment-specific schema variants: A products_draft table used only in dev for content team previews
  • Feature flags: Migrations for a feature that is ready for staging but not approved for production yet

Contexts and labels solve all of these without splitting the changelog.


Contexts

A context is a string tag you attach to a changeset. When Liquibase runs, you tell it which contexts are active. Only changesets whose context matches the active set are applied.

Attaching a context to a changeset

- changeSet:
    id: "20260522-001"
    author: abhay
    context: dev
    comment: Seed dev admin user — never runs in prod
    changes:
      - insert:
          tableName: users
          columns:
            - column: {name: email, value: "admin@dev.local"}
            - column: {name: full_name, value: "Dev Admin"}
            - column: {name: password_hash, value: "$2a$10$placeholder"}
            - column: {name: role, value: "admin"}
            - column: {name: status, value: "active"}
            - column: {name: email_verified, valueNumeric: 1}
    rollback:
      - delete:
          tableName: users
          where: email = 'admin@dev.local'

Specifying active contexts at runtime

CLI:

# Only run changesets with context 'dev'
liquibase update --context-filter=dev

# Run changesets with context 'dev' OR 'staging'
liquibase update --context-filter="dev,staging"

# Run changesets with context 'staging' AND NOT 'slow'
liquibase update --context-filter="staging and not slow"

Spring Boot (application-dev.yml):

spring:
  liquibase:
    contexts: dev,default

Spring Boot (application-prod.yml):

spring:
  liquibase:
    contexts: prod,default

The default context

A changeset with no context attribute runs in all environments, regardless of which contexts are active. This is the correct behaviour for schema migrations — they must run everywhere.

A changeset with a context attribute only runs when that context is active. This is the correct behaviour for seed data and environment-specific changes.

Naming one of your active contexts default is a convention (not a Liquibase keyword) that makes it explicit: “these changesets run in every environment.” Some teams use all instead. Either works — the important thing is consistency.

Context expressions

Contexts support boolean expressions for complex filtering:

# Runs in dev OR staging (not prod)
context: dev, staging

# Runs ONLY when both 'staging' AND 'performance' are active
context: staging and performance

# Runs in dev but NOT when 'minimal' is also active
context: dev and not minimal

The expression is evaluated on the changeset side. You write the logic in the changeset, and the runtime just tells Liquibase which contexts are active.


Labels

Labels look similar to contexts but operate on the opposite side of the relationship:

  • Context: logic is in the changeset, runtime provides a simple list of active contexts
  • Label: changeset has a simple tag list, runtime provides the complex expression
# Context — complex logic in the changeset
- changeSet:
    id: "20260523-001"
    context: "staging and not slow"
    ...

# Label — simple tag in the changeset, complex filtering at runtime
- changeSet:
    id: "20260523-002"
    labels: "v1.2, payments-feature"
    ...
# At runtime, deploy only label 'v1.2' but not 'slow' changesets
liquibase update --label-filter="v1.2 and not slow"

# Deploy either payments-feature OR account-feature
liquibase update --label-filter="payments-feature or account-feature"

Spring Boot:

spring:
  liquibase:
    label-filter: "v1.2 and not slow"

When to use labels vs contexts

The Liquibase documentation recommends thinking about it this way:

ScenarioUse
Changeset author controls which environments run itContext
Deployment manager controls which changesets run at deploy timeLabel
Seed data, test data, environment variantsContext
Feature rollouts, performance-sensitive migrations, release trainsLabel

In practice, most teams use contexts for environment filtering and labels for feature/release gating. You can use both on the same changeset:

- changeSet:
    id: "20260524-001"
    context: "staging,prod"
    labels: "payments-v2"
    comment: Payments v2 schema — runs in staging/prod when payments-v2 label is active

This changeset only runs when: the active context includes staging or prod AND the label filter matches payments-v2.


Real-World Configuration Patterns

Pattern 1: Environment Seed Data

# db/changelog/migrations/2026/05/20260525-seed.yaml
databaseChangeLog:

  # Runs everywhere — reference data, not environment-specific
  - changeSet:
      id: "20260525-001"
      author: abhay
      comment: Seed product categories — runs in all environments
      changes:
        - insert:
            tableName: product_categories
            columns:
              - column: {name: name, value: "Electronics"}
              - column: {name: slug, value: "electronics"}
        - insert:
            tableName: product_categories
            columns:
              - column: {name: name, value: "Clothing"}
              - column: {name: slug, value: "clothing"}
      rollback:
        - delete:
            tableName: product_categories
            where: slug IN ('electronics', 'clothing')

  # Only dev and staging — test users
  - changeSet:
      id: "20260525-002"
      author: abhay
      context: dev,staging
      comment: Seed test user accounts
      changes:
        - insert:
            tableName: users
            columns:
              - column: {name: email, value: "buyer@test.local"}
              - column: {name: full_name, value: "Test Buyer"}
              - column: {name: password_hash, value: "$2a$10$placeholder"}
              - column: {name: role, value: "customer"}
              - column: {name: status, value: "active"}
              - column: {name: email_verified, valueNumeric: 1}
      rollback:
        - delete:
            tableName: users
            where: email LIKE '%@test.local'

  # Only dev — large synthetic dataset for UI development
  - changeSet:
      id: "20260525-003"
      author: abhay
      context: dev
      comment: Load synthetic product catalog for UI development
      changes:
        - loadData:
            tableName: products
            file: db/data/synthetic-products.csv
      rollback:
        - delete:
            tableName: products
            where: "1=1"

Spring Boot profile wiring:

# application.yml (base)
spring:
  liquibase:
    enabled: true

# application-dev.yml
spring:
  liquibase:
    contexts: dev,staging,default

# application-staging.yml
spring:
  liquibase:
    contexts: staging,default

# application-prod.yml
spring:
  liquibase:
    contexts: prod,default

With this setup:

  • Schema changesets (no context) → run everywhere
  • context: dev,staging → run in dev and staging, skip in prod
  • context: dev → run only in dev
  • context: prod → run only in prod (for prod-specific configuration changesets)

Pattern 2: Feature-Gated Migrations with Labels

Use labels to hold back migrations for features that are code-complete but not yet approved for deployment:

- changeSet:
    id: "20260526-001"
    author: abhay
    labels: "coupons-feature"
    comment: Add coupons table — deploy when coupons feature is approved
    changes:
      - createTable:
          tableName: coupons
          columns:
            - column:
                name: id
                type: BIGINT UNSIGNED
                autoIncrement: true
                constraints:
                  primaryKey: true
                  nullable: false
            - column:
                name: code
                type: VARCHAR(50)
                constraints:
                  nullable: false
                  unique: true
            - column:
                name: discount_type
                type: ENUM('percentage','fixed')
                constraints:
                  nullable: false
            - column:
                name: discount_value
                type: DECIMAL(8,2)
                constraints:
                  nullable: false
            - column:
                name: expires_at
                type: DATETIME
    rollback:
      - dropTable:
          tableName: coupons

Deploy without the coupons feature (label not activated):

# No --label-filter — all changesets without labels run, labeled ones skip
liquibase update

Deploy with the coupons feature when approved:

liquibase update --label-filter="coupons-feature"

Spring Boot:

# application-prod.yml when coupons feature is approved for release
spring:
  liquibase:
    label-filter: "coupons-feature"

Pattern 3: Performance-Sensitive Migration Gating

Some migrations are safe to run but slow on large tables. Label them so they only execute in scheduled maintenance windows:

- changeSet:
    id: "20260527-001"
    author: abhay
    labels: "slow,maintenance-window"
    comment: Add full-text index on products.description — slow on large table
    changes:
      - sql:
          sql: >
            ALTER TABLE products
            ADD FULLTEXT INDEX ft_products_description (description);            
    rollback:
      - sql:
          sql: >
            ALTER TABLE products
            DROP INDEX ft_products_description;            

Normal deployment (skips slow migrations):

liquibase update --label-filter="not slow"

Maintenance window deployment (includes slow migrations):

liquibase update --label-filter="slow"

What Happens With No Active Context

If you run liquibase update with no --context-filter and no spring.liquibase.contexts:

  • Changesets with no context attributerun (this is the default behaviour)
  • Changesets with a context attributealso run (no filter means no restriction)

This is a footgun. Running without any context filter means every dev seed data changeset runs — including ones that insert test users into production.

Always set spring.liquibase.contexts in every environment config. Never rely on the no-filter default outside of local development with a throwaway database.


Inspecting Context Behaviour

liquibase status respects the active context/label filter — it shows only the changesets that would run:

# See what would run with context 'prod,default'
liquibase status --context-filter="prod,default"

# See what would run with label filter 'coupons-feature'
liquibase status --label-filter="coupons-feature"

Use this during deployments to verify the right changesets are in scope before running update.


Common Mistakes

Not setting contexts in production: Running Liquibase in production without --context-filter causes all context-tagged changesets (including context: dev ones) to run. Always configure spring.liquibase.contexts in the production profile, even if it is just default.

Using context for reference data that should run everywhere: Reference data like country codes, currency codes, and product categories should run in all environments. Do not tag them with context: prod — they belong in production, staging, and dev. Only tag changesets that should not run everywhere.

Forgetting that label expressions are on the runtime side: A changeset with labels: "v1.2" will run when the label filter is "v1.2 or v1.3". If you want to restrict a changeset to running only with a specific expression, use context instead — context logic is in the changeset, label logic is at runtime.


Best Practices

  • No context = runs everywhere — schema migrations should never have a context attribute
  • context: dev for seed and test data — the safest way to prevent test data from reaching production
  • Always set spring.liquibase.contexts in every profile — never rely on the no-filter default in any shared environment
  • Use labels for deployment-time feature gating — the ops team or CI pipeline controls which features deploy without changing changelogs
  • liquibase status --context-filter=X before deployment — confirm the right changesets are in scope

What You’ve Learned

  • Changesets with no context run in all environments — use this for all schema changes
  • context filters are written in the changeset; label filters are written at runtime — choose based on who controls the logic
  • Boolean expressions (and, or, not) work in both context and label filters
  • Spring Boot profiles wire context/label configuration per environment through spring.liquibase.contexts and label-filter
  • Running without any context filter runs every changeset — always configure contexts in shared environments
  • Labels enable deployment-time feature gating without changing the changelog

Next: Article 11 — Preconditions: Guard Your Migrations with tableExists, sqlCheck, and More — how to make changesets self-protecting so they skip or fail gracefully when the database is not in the expected state.