Part 7 of 18

Changelog Organization: Master Files, include/includeAll, Directory Structures

A single changelog file works fine for a tutorial. It stops working the moment two developers add migrations on the same day, or when you need to find the changeset that introduced a column added six months ago, or when a feature branch’s migrations must be reviewed independently before merging.

Changelog organization is not a polish step — it is what determines whether Liquibase stays manageable at scale or turns into a source of merge conflicts and mystery failures. This article covers the tools Liquibase gives you (include, includeAll, master changelogs) and the structures that work in real projects.


The Master Changelog Pattern

The master changelog is the single file you point Liquibase at — the one referenced by liquibase.properties or spring.liquibase.change-log. It contains no changesets itself. Its only job is to reference other files.

# db/changelog/db.changelog-master.yaml
databaseChangeLog:
  - includeAll:
      path: db/changelog/migrations/
      relativeToChangelogFile: false

This separation matters for two reasons:

  1. Single entry point. The command liquibase update (or Spring Boot startup) always points at one file. That file never changes structure — only the migration files it pulls in grow over time.
  2. Reviewability. In a code review, you can open db.changelog-master.yaml and immediately understand the shape of the migration system without reading individual changesets.

include vs includeAll

Liquibase gives you two ways to pull in external changelog files.

include — explicit, ordered

include references one specific file:

databaseChangeLog:
  - include:
      file: db/changelog/migrations/2026/05/001-create-users-table.yaml
      relativeToChangelogFile: false

  - include:
      file: db/changelog/migrations/2026/05/002-create-products-table.yaml
      relativeToChangelogFile: false

Use include when:

  • You need to control the exact execution order that alphabetical sorting cannot guarantee
  • You are including a special file from a different path (e.g., a shared library of stored procedures)
  • You want to explicitly exclude certain files from a directory without building a custom filter

Drawback: Every new migration file must be manually added to the master changelog. In a team of five developers, that means five simultaneous edits to the same master file — merge conflicts on every PR.

includeAll — automatic, alphabetical

includeAll picks up every changelog file in a directory (and its subdirectories) in alphabetical order:

databaseChangeLog:
  - includeAll:
      path: db/changelog/migrations/
      relativeToChangelogFile: false

Use includeAll when:

  • Multiple developers are adding migration files simultaneously
  • You want new files to be picked up automatically without touching the master changelog
  • Your naming convention guarantees correct alphabetical ordering

The critical dependency: includeAll order is alphabetical. Your file naming convention must encode execution order. If it does not, a file named add-column.yaml will always run before create-table.yaml, regardless of when it was created.

relativeToChangelogFile

This attribute changes how Liquibase resolves the path:

# Absolute path from the classpath root
- includeAll:
    path: db/changelog/migrations/
    relativeToChangelogFile: false    # default

# Path relative to the master changelog file's location
- includeAll:
    path: migrations/
    relativeToChangelogFile: true

relativeToChangelogFile: true is useful when the master changelog is not at the classpath root — it lets you move the whole changelog directory without updating paths. For Spring Boot projects where the master changelog is at src/main/resources/db/changelog/db.changelog-master.yaml, both forms work; relative paths are more portable.

includeAll with a filter

By default, includeAll picks up all .yaml, .xml, .json, and .sql files. You can restrict which files are included by writing a class that implements IncludeAllFilter:

package com.example.liquibase;

import liquibase.changelog.IncludeAllFilter;

public class ExcludeTestDataFilter implements IncludeAllFilter {
    @Override
    public boolean include(String changeLogPath) {
        // Skip files named *-test-data.yaml
        return !changeLogPath.contains("-test-data");
    }
}

Reference it in the master changelog:

- includeAll:
    path: db/changelog/migrations/
    filter: com.example.liquibase.ExcludeTestDataFilter

In practice, filtering by context (Article 10) is cleaner than a custom filter — but the filter is useful when you need to physically separate files that should never run together.


Naming Conventions

The naming convention for changelog files is the foundation of includeAll ordering. Get it wrong and you’ll spend an afternoon debugging why a column migration ran before its table was created.

Option A — Date + sequence (recommended for teams):

YYYYMMDD-NNN-description.yaml
20260503-001-create-users-table.yaml
20260503-002-create-products-table.yaml
20260510-001-add-users-phone-column.yaml

The date prefix groups by when the migration was written. The sequence number handles multiple migrations on the same day. This format:

  • Sorts correctly alphabetically
  • Makes git log -- db/changelog/migrations/ readable
  • Allows two developers to write migrations on the same day without colliding (different sequence numbers)

Option B — Global sequence (simpler, riskier):

0001-create-users-table.yaml
0002-create-products-table.yaml
0003-add-users-phone-column.yaml

Simpler, but two developers both writing “the next migration” will grab the same sequence number. Requires coordination or a centralized sequence registry.

Option C — Release + sequence (release-based teams):

v1.0.0-001-create-users-table.yaml
v1.0.0-002-create-products-table.yaml
v1.1.0-001-add-users-phone-column.yaml

Groups migrations by release. Useful when your changelog needs to align with versioned software releases.

Changeset ID convention

The changeset id inside the file should match the file prefix to make tracing easy:

# File: 20260503-001-create-users-table.yaml
- changeSet:
    id: "20260503-001"
    author: abhay

The combination of id + author + filename is the unique key in DATABASECHANGELOG. Using the date-prefix as the ID means the tracking table row immediately tells you when the migration was written.


Directory Structure Patterns

There is no single correct structure. The right choice depends on team size, release cadence, and whether the database is shared across multiple applications. Here are the four most common patterns.

db/changelog/
├── db.changelog-master.yaml
└── migrations/
    └── 2026/
        ├── 05/
        │   ├── 20260503-001-create-users-table.yaml
        │   ├── 20260503-002-create-products-table.yaml
        │   └── 20260510-001-add-users-phone-column.yaml
        └── 06/
            └── 20260601-001-create-orders-table.yaml

Master changelog:

databaseChangeLog:
  - includeAll:
      path: db/changelog/migrations/
      relativeToChangelogFile: false

includeAll recurses into subdirectories, so the 2026/05/ partitioning is automatic.

Best for: Most teams. Files are naturally grouped by time, git log on the directory gives a clean history, and merge conflicts are rare because developers work in different months most of the time.

Drawback: Finding all migrations for a specific feature requires git log --grep rather than browsing a directory.


Pattern 2: Release-Partitioned

db/changelog/
├── db.changelog-master.yaml
└── releases/
    ├── v1.0.0/
    │   ├── 001-create-users-table.yaml
    │   └── 002-create-products-table.yaml
    └── v1.1.0/
        ├── 001-add-users-phone-column.yaml
        └── 002-create-orders-table.yaml

Master changelog (explicit include per release directory):

databaseChangeLog:
  - includeAll:
      path: db/changelog/releases/v1.0.0/
      relativeToChangelogFile: false

  - includeAll:
      path: db/changelog/releases/v1.1.0/
      relativeToChangelogFile: false

Note that each release directory is an explicit includeAll rather than a recursive scan of releases/. This prevents a new release directory from being picked up before its migrations are ready — you add the includeAll line when the release is ready to merge.

Best for: Teams with formal release trains, where migrations must be reviewed as a release unit.

Drawback: The master changelog must be updated when a new release directory is added — a single-line change, but still a touch point for merge conflicts.


Pattern 3: Object-Type Partitioned

db/changelog/
├── db.changelog-master.yaml
└── schema/
    ├── tables/
    │   ├── 001-users.yaml
    │   └── 002-products.yaml
    ├── indexes/
    │   ├── 001-users-indexes.yaml
    │   └── 002-products-indexes.yaml
    ├── views/
    │   └── 001-user-order-summary.yaml
    └── data/
        └── 001-seed-categories.yaml

Master changelog (explicit order by object type):

databaseChangeLog:
  - includeAll:
      path: db/changelog/schema/tables/
      relativeToChangelogFile: false

  - includeAll:
      path: db/changelog/schema/indexes/
      relativeToChangelogFile: false

  - includeAll:
      path: db/changelog/schema/views/
      relativeToChangelogFile: false

  - includeAll:
      path: db/changelog/schema/data/
      relativeToChangelogFile: false

The master changelog enforces object-type ordering: tables before indexes before views before data. This guarantees that a view referencing a table always runs after the table is created.

Best for: Database-heavy projects managed by a DBA team where organization by database object type is more natural than by date or release.

Drawback: As the project grows, individual type directories get large. Mixing “when was this created” with “what type is this” is harder to reason about in git log.


Pattern 4: Feature-Branch Staging Area (Advanced)

This pattern is for teams where feature branches live for a long time and migrations on multiple branches must be integrated without conflict.

db/changelog/
├── db.changelog-master.yaml
├── migrations/           ← merged, stable migrations
│   └── 2026/
│       └── 05/
│           └── 20260503-001-create-users-table.yaml
└── pending/              ← feature migrations awaiting merge
    ├── feature-payments/
    │   └── 001-create-payments-table.yaml
    └── feature-coupons/
        └── 001-create-coupons-table.yaml

Master changelog:

databaseChangeLog:
  # Stable migrations — always run
  - includeAll:
      path: db/changelog/migrations/
      relativeToChangelogFile: false

  # Pending feature migrations — included during development, removed on merge
  - includeAll:
      path: db/changelog/pending/
      relativeToChangelogFile: false

When a feature branch merges to main, its migration files are moved from pending/feature-name/ to migrations/YYYY/MM/ with the correct date prefix. The pending/ directory entry in the master changelog can stay as-is — an empty directory with no files produces no error.

Best for: Teams where feature branches run independent environments and need isolated migration sets. Uncommon — most teams find Pattern 1 sufficient.


The e-commerce tutorial uses Pattern 1 (date-partitioned) because it scales to any team size and produces the most readable git history.

src/main/resources/
└── db/
    └── changelog/
        ├── db.changelog-master.yaml
        └── migrations/
            └── 2026/
                ├── 05/
                │   ├── 20260503-001-create-users-table.yaml
                │   ├── 20260503-002-create-products-table.yaml
                │   └── 20260503-003-create-product-categories-table.yaml
                └── 06/
                    └── 20260601-001-create-orders-table.yaml
# db.changelog-master.yaml — never changes structure
databaseChangeLog:
  - includeAll:
      path: db/changelog/migrations/
      relativeToChangelogFile: false

With this setup, adding a new migration is exactly one action: create a new file with the correct date prefix. The master changelog, CI pipeline, and Spring Boot configuration never need to change.


Splitting One Large File Into Multiple

If you have inherited a project with a single enormous changelog file, here is how to split it without breaking existing databases.

The wrong way: Delete the original file and create many smaller files. Liquibase will try to re-apply every changeset (because the FILENAME column in DATABASECHANGELOG changed) and fail on every one.

The right way:

  1. Keep the original file in place and add it as an explicit include at the top of the master changelog
  2. Create new files for future changesets
  3. Optionally run changelogSync on new environments to baseline them
# db.changelog-master.yaml during migration
databaseChangeLog:
  # Legacy monolith file — do not rename or move
  - include:
      file: db/changelog/legacy/db.changelog-v1.yaml
      relativeToChangelogFile: false

  # New structured migrations
  - includeAll:
      path: db/changelog/migrations/
      relativeToChangelogFile: false

The FILENAME stored in DATABASECHANGELOG is the path as Liquibase saw it when the changeset first ran. Renaming or moving the file changes that path, and Liquibase considers the changeset untracked (and tries to re-run it). Never rename or move a file that has changesets already applied to any environment.


Common Mistakes

Naming files without a sortable prefix: Files named create-users.yaml, add-column.yaml, create-orders.yaml sort alphabetically in an unexpected order. add-column.yaml runs before create-orders.yaml which runs before create-users.yaml — but add-column references the users table that hasn’t been created yet. Alphabetical ordering is rigid; your names must encode the intended order.

Touching the master changelog on every PR: If the master changelog uses explicit include lines, every developer adding a migration also edits the master changelog. This creates merge conflicts on every PR. Switch to includeAll — then the master changelog is never touched after initial setup.

Moving a file after it has been applied to any database: Liquibase stores the full file path in DATABASECHANGELOG. Moving migrations/001-create-users.yaml to migrations/2026/05/001-create-users.yaml changes the stored path. On the next update, Liquibase sees the file as new and tries to re-apply it — failing because the users table already exists. Treat applied changelog file paths as immutable, exactly like changeset IDs.


Best Practices

  • Master changelog contains no changesets — only include/includeAll references; keep it structurally stable
  • Use includeAll over include in most cases — it eliminates master changelog merge conflicts
  • Date-prefix all filenamesYYYYMMDD-NNN-description.yaml guarantees alphabetical = chronological
  • Match changeset ID to filename prefixid: "20260503-001" makes DATABASECHANGELOG rows self-documenting
  • Never rename or move an applied file — the stored path in DATABASECHANGELOG is as immutable as the changeset ID
  • Recurse subdirectories with one includeAll — date-partitioned month folders are picked up automatically; no master changelog update needed when a new month starts

What You’ve Learned

  • The master changelog is a structural file — it contains no changesets, only references to other files
  • include is explicit and ordered; includeAll is automatic and alphabetical — choose based on team workflow
  • relativeToChangelogFile: true makes paths portable when the master changelog is not at the classpath root
  • File naming convention is the load-bearing element of includeAll ordering — date-prefix guarantees chronological execution
  • Four directory structures cover most real projects: date-partitioned, release-partitioned, object-type, and feature-staged
  • Moving or renaming an applied changelog file breaks DATABASECHANGELOG tracking — treat file paths as immutable

Next: Article 8 — Common Migration Patterns: 12 Real-World Schema Changes with MySQL — the practical changesets you’ll write over and over: adding columns, renaming, creating foreign keys, adding indexes, changing types, and more.