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_drafttable 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:
| Scenario | Use |
|---|---|
| Changeset author controls which environments run it | Context |
| Deployment manager controls which changesets run at deploy time | Label |
| Seed data, test data, environment variants | Context |
| Feature rollouts, performance-sensitive migrations, release trains | Label |
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 prodcontext: dev→ run only in devcontext: 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
contextattribute → run (this is the default behaviour) - Changesets with a
contextattribute → also 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: devfor seed and test data — the safest way to prevent test data from reaching production- Always set
spring.liquibase.contextsin 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=Xbefore deployment — confirm the right changesets are in scope
What You’ve Learned
- Changesets with no
contextrun in all environments — use this for all schema changes contextfilters are written in the changeset;labelfilters 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.contextsandlabel-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.