Welcome to the ultimate Liquibase Learning Guide! As your expert Liquibase educator and senior database DevOps practitioner, I’m thrilled to embark on this journey with you. This guide is designed to take you from an absolute beginner to an expert in managing your database changes with Liquibase, covering everything from fundamental concepts to advanced CI/CD patterns and enterprise-grade practices. We’ll emphasize safety, best practices, and the “why” behind every step, ensuring you develop an expert mindset.
This guide is current with Liquibase OSS/Pro version 4.33 conventions and leverages the official Liquibase Docker image. Where applicable, we’ll cite official approaches and highlight differences between the Open Source (OSS) and Pro editions. Get ready for hands-on practice, practical examples, and a deep dive into the world of database change management!
Let’s begin!
0) Quick Overview and Mental Model
What you’ll learn
- The core purpose of Liquibase.
- Key Liquibase terms: changelog, changeset,
DATABASECHANGELOG,DATABASECHANGELOGLOCK. - Why database changes are unique compared to application deployments.
- A brief comparison with Flyway.
Why it matters
Understanding the fundamental purpose and mental model of Liquibase is crucial before diving into the commands and configurations. It sets the stage for why database change management is essential, especially in modern DevOps practices, and how Liquibase helps you achieve reliable, repeatable, and safe database deployments. Unlike stateless application code, databases are stateful, meaning every change can have long-lasting implications. Liquibase provides a structured, version-controlled way to manage these changes, mitigating risks and enabling automation.
Concepts explained simply
Imagine your database schema as a living, evolving entity. Every time you add a table, modify a column, or update some reference data, you’re making a change. Without a proper system, these changes can become chaotic, leading to inconsistencies, difficult rollbacks, and deployment failures across environments.
Liquibase is a powerful open-source (with a commercial Pro offering) tool that helps you version, track, and deploy your database schema changes as code. Think of it as Git for your database. You define your database changes in files (called changelogs), Liquibase then applies these changes to your database, recording what was applied, when, and by whom.
Here are the core concepts:
- Changelog: This is the main file (or a collection of files) that defines all the changes you want to apply to your database. It’s an ordered list of changesets. You can write changelogs in various formats: SQL, XML, YAML, or JSON.
- Changeset: This is the smallest unit of change in Liquibase. Each changeset has a unique ID, an author, and a
logicalFilePath. It represents a single, atomic database modification (e.g., creating a table, adding a column, inserting data). Once a changeset is run, it should ideally never be changed. If you need to modify an applied changeset, you typically create a new one to fix forward, or you usechangelog-sync-forcewith extreme caution. DATABASECHANGELOGTable: This is a table that Liquibase automatically creates in your database (if it doesn’t exist). It acts as the “source of truth” for which changesets have been applied to that specific database. Each row in this table corresponds to an applied changeset, recording its ID, author, date, checksum, and other metadata.DATABASECHANGELOGLOCKTable: This is another table Liquibase automatically creates. It’s used to prevent multiple Liquibase instances from trying to update the same database simultaneously, which could lead to conflicts or data corruption. Liquibase acquires a lock on this table before running any commands that modify the database, and releases it afterward.
Why are database rollbacks harder than app rollbacks?
When you deploy a new version of an application, rolling back often means deploying the previous version of the code. This is usually straightforward because application code is largely stateless.
Databases are different. They are stateful. If you deploy a new application version with a schema change (e.g., adding a new column) and then realize you need to roll back the application, simply reverting the app code might not be enough. The database still has the new column. If the previous application version doesn’t know about that column, it might fail.
Moreover, if data has been inserted into that new column, a naive rollback of the schema (e.g., dropping the column) could lead to data loss. This is why Liquibase emphasizes careful change management, safe rollback strategies, and the “fix-forward” mindset, where you often introduce a new changeset to correct an issue rather than trying to revert an already applied one.
Brief Comparison with Flyway
While both Liquibase and Flyway are popular database migration tools, they have some key differences:
- Changelog Formats: Flyway primarily uses plain SQL scripts. Liquibase offers more flexibility with SQL, XML, YAML, and JSON.
- Rollback Capabilities: Liquibase has richer built-in rollback capabilities (e.g.,
rollback-to-tag,rollback-count, explicitrollbacktags in changesets). Flyway generally encourages a “fix-forward” approach and doesn’t have the same native rollback features. - Abstraction: Liquibase’s XML/YAML/JSON formats provide a higher level of abstraction, allowing the same changelog to be more easily applied across different database types (though careful testing is always required). Flyway’s SQL scripts are often more database-specific.
- Pro Features: Liquibase has a more extensive commercial “Pro” offering with features like advanced drift detection, policy checks, complex deployment flows, and enhanced observability. Flyway also has a paid offering, but with a different feature set.
- Desktop UI: Flyway has a desktop UI for some operations. Liquibase is primarily CLI/API driven, though Liquibase Hub provides a web-based reporting and monitoring interface.
Troubleshooting and verification steps
- Verifying Liquibase Installation: Running
liquibase --versionshould output the installed version. - Checking Lock Status: If Liquibase seems stuck, you might see an entry in the
DATABASECHANGELOGLOCKtable withLOCKED=TRUE. You can manually clear it (carefully!) if you’re sure no other Liquibase process is running. - Examining
DATABASECHANGELOG: After any Liquibase operation, inspect theDATABASECHANGELOGtable in your database to see which changesets were applied.
Takeaway checklist
- Understand Liquibase’s core purpose: versioning and deploying DB changes.
- Familiarize yourself with changelogs and changesets.
- Know the role of
DATABASECHANGELOGandDATABASECHANGELOGLOCK. - Appreciate the unique challenges of database rollbacks.
- Have a high-level idea of how Liquibase compares to Flyway.
1) Installation and Setup (Windows/macOS/Linux/Docker)
What you’ll learn
- How to install Liquibase on Windows, macOS, and Linux.
- How to verify a successful installation.
- How to use the official Liquibase Docker image for various database operations.
- Understanding Docker volume mounts, environment variables, and defaults files.
- Handling JDBC drivers for different databases, including special considerations for MySQL.
Why it matters
A correct and consistent installation across development and CI/CD environments is fundamental for reliable database change management. Docker provides an excellent way to standardize your Liquibase execution, ensuring that everyone on the team and all automated pipelines use the same version and configuration. Understanding how to manage JDBC drivers is crucial, as Liquibase needs these to connect to your specific database.
Concepts explained simply
Liquibase is a Java application, so its installation involves downloading the necessary binaries or leveraging package managers. For Docker, it’s about pulling the pre-built image and understanding how to provide it with your changelogs, configuration, and database connection details.
Step-by-step lab(s) with copy-pasteable commands and expected output
Let’s get Liquibase set up on various platforms.
1.1) Installation on Windows
Method 1: Using the Installer (Recommended for Beginners)
- Download the Installer: Go to the official Liquibase download page (e.g.,
liquibase.com/download) and download the Windows installer (.msi). - Run the Installer:
- Double-click the downloaded
.msifile. - Follow the on-screen prompts. Ensure you check the option to add Liquibase to your system’s PATH during installation.
- Double-click the downloaded
- Verify Installation:
- Open a new Command Prompt or PowerShell window (important: open a new one to ensure PATH changes are picked up).
- Type:
liquibase --version - Expected Output:
(The version number might vary slightly depending on the latest release.)Liquibase Version: 4.33.0 Liquibase Pro License Key: Not Found
Method 2: Manual Installation
- Download the ZIP: Go to the Liquibase download page and download the platform-independent ZIP archive.
- Extract the Archive: Extract the contents of the ZIP file to a directory of your choice (e.g.,
C:\liquibase). - Add to PATH:
- Search for “Environment Variables” in the Windows search bar and select “Edit the system environment variables”.
- Click “Environment Variables…”
- Under “System variables”, select “Path” and click “Edit…”.
- Click “New” and add the path to the
liquibasedirectory (e.g.,C:\liquibase). - Click “OK” on all windows to close them.
- Verify Installation: Open a new Command Prompt or PowerShell window and run
liquibase --versionas shown above.
1.2) Installation on macOS/Linux
Method 1: Using Homebrew (macOS)
- Install Homebrew (if you don’t have it):
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - Install Liquibase:
brew install liquibase - Verify Installation:Expected Output: Similar to Windows output.
liquibase --version
Method 2: Manual Installation (macOS/Linux)
- Download the ZIP: Go to the Liquibase download page and download the platform-independent ZIP archive.
- Extract the Archive: Choose a suitable location (e.g.,
/optor$HOME/tools).(Adjust the ZIP file name and destination as needed).sudo mkdir -p /opt/liquibase sudo unzip ~/Downloads/liquibase-4.33.0.zip -d /opt/liquibase - Add to PATH: Edit your shell’s initialization file (e.g.,
~/.bashrc,~/.zshrc,~/.profile).echo 'export PATH="/opt/liquibase:$PATH"' >> ~/.zshrc # Or .bashrc/.profile source ~/.zshrc # Or .bashrc/.profile - Verify Installation:Expected Output: Similar to Windows output.
liquibase --version
1.3) Installation via Docker
Using Docker for Liquibase is highly recommended for consistency and ease of setup across environments.
Pull the Official Image: Liquibase publishes official images to Docker Hub. It’s good practice to use a specific version tag for stability.
4.33.0is used here as an example forlatest. Always check Docker Hub for the latest recommended tags.docker pull liquibase/liquibase:4.33.0 # Or for a lightweight Alpine-based image: docker pull liquibase/liquibase:4.33.0-alpinePrepare a Project Directory: Create a simple project directory with a changelog file and a
liquibase.propertiesfile.my-liquibase-project/changelog.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"> <changeSet id="1" author="yourname"> <createTable tableName="test_table"> <column name="id" type="int"> <constraints primaryKey="true" nullable="false"/> </column> <column name="name" type="varchar(255)"/> </createTable> </changeSet> </databaseChangeLog>my-liquibase-project/liquibase.properties:# Example for H2 in-memory # Change this to your actual database URL, username, and password url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 # H2 driver is bundled with Liquibase driver: org.h2.Driver username: sa password: changelog-file: changelog.xml
Run an Update via Mounted Changelogs and Defaults File: You need to mount your project directory into the Docker container. The official Liquibase image expects changelogs to be in
/liquibase/changelogby default.docker run --rm -v $(pwd)/my-liquibase-project:/liquibase/changelog liquibase/liquibase:4.33.0 \ --defaults-file=/liquibase/changelog/liquibase.properties \ update--rm: Automatically remove the container after it exits.-v $(pwd)/my-liquibase-project:/liquibase/changelog: Mounts your localmy-liquibase-projectdirectory into the container at/liquibase/changelog.--defaults-file=/liquibase/changelog/liquibase.properties: Tells Liquibase to use theliquibase.propertiesfile located inside the mounted volume.update: The Liquibase command to execute.
Expected Output (truncated):
... Liquibase 4.33.0 by Liquibase ... Successfully acquired change log lock ... Table TEST_TABLE created ... ChangeSet changelog.xml::1::yourname ran successfully in ... ... Successfully released change log lock Liquibase 'update' successful.Note: On first run against a new H2 in-memory database, Liquibase will also create the
DATABASECHANGELOGandDATABASECHANGELOGLOCKtables.Using Environment Variables for Database Credentials: For production scenarios, it’s often better to pass sensitive information via environment variables rather than directly in
liquibase.properties(which might be committed to version control).docker run --rm \ -v $(pwd)/my-liquibase-project:/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1" \ -e LIQUIBASE_COMMAND_USERNAME="sa" \ -e LIQUIBASE_COMMAND_PASSWORD="" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="changelog.xml" \ liquibase/liquibase:4.33.0 \ update-e LIQUIBASE_COMMAND_URL=...: Liquibase automatically picks up environment variables prefixed withLIQUIBASE_COMMAND_and maps them to command-line parameters. This is a secure and flexible way to pass parameters.
Expected Output: Same as above.
1.4) JDBC Driver Notes
- Bundled Drivers: Liquibase typically bundles drivers for common databases like H2.
- MySQL/MariaDB Driver: Due to licensing restrictions, the MySQL (Connector/J) and MariaDB drivers are NOT bundled with the Liquibase installer or the official Docker image. You must install them separately.
- PostgreSQL, SQL Server, Oracle: Drivers for these are often (but not always, check specific Liquibase versions and releases) bundled or can be easily installed. When in doubt, it’s safer to explicitly add the driver.
Demonstrating JDBC Driver Installation (MySQL Example)
Using Liquibase Package Manager (LPM): LPM is a convenient way to install extensions and drivers.
- Local Install:This will download the MySQL driver to your Liquibase installation directory’s
liquibase lpm install --global mysql --version=8.0.33 # Or desired version, check official Maven repo or MySQL downloadslibfolder. - Docker Install (Ephemeral): If you run Liquibase in Docker without a custom image, you can install the driver at runtime, though this adds overhead and is typically less efficient for repeated runs:Note: The LPM install in the first command only makes the driver available for that specific container run. You’d need to re-install for subsequent runs or use a custom image.
# This is a less common pattern for production, but shows the command docker run --rm \ -v $(pwd)/my-liquibase-project:/liquibase/changelog \ liquibase/liquibase:4.33.0 \ lpm install --global mysql --version=8.0.33 && \ docker run --rm \ -v $(pwd)/my-liquibase-project:/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:mysql://host.docker.internal:3306/testdb" \ -e LIQUIBASE_COMMAND_DRIVER="com.mysql.cj.jdbc.Driver" \ -e LIQUIBASE_COMMAND_USERNAME="root" \ -e LIQUIBASE_COMMAND_PASSWORD="mysecretpassword" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="changelog.xml" \ liquibase/liquibase:4.33.0 \ update
- Local Install:
Custom Dockerfile (Recommended for Production Docker): For a robust and production-ready Docker setup, create a custom Dockerfile that layers the necessary JDBC drivers on top of the official Liquibase image.
my-custom-liquibase/Dockerfile:FROM liquibase/liquibase:4.33.0 # Install MySQL JDBC Driver (adjust version as needed). # Always use a specific version for stability. # Check https://dev.mysql.com/downloads/connector/j/ or Maven Central for latest direct download URLs. # Example for MySQL Connector/J 8.0.33 RUN mkdir -p /liquibase/drivers && \ wget -O /liquibase/drivers/mysql-connector-java-8.0.33.jar \ https://repo1.maven.org/maven2/mysql/mysql-connector-java/8.0.33/mysql-connector-java-8.0.33.jar # Set LIQUIBASE_CLASSPATH to include the drivers directory. # Liquibase automatically scans this path for JDBC drivers. ENV LIQUIBASE_CLASSPATH=/liquibase/driversmy-custom-liquibase/liquibase.properties:# Example for MySQL connection url: jdbc:mysql://host.docker.internal:3306/testdb # Use host.docker.internal for macOS/Windows Docker Desktop driver: com.mysql.cj.jdbc.Driver username: root password: mysecretpassword # Use environment variables in CI/CD! changelog-file: changelog.xml- Note on
host.docker.internal: This special DNS name allows Docker containers running on macOS and Windows (using Docker Desktop) to connect to services running on the host machine’slocalhost. On Linux, this typically isn’t available; you’d use the host’s actual IP address or ensure the database is on a shared Docker network.
- Note on
Build the Custom Image:
cd my-custom-liquibase docker build -t my-liquibase-mysql:4.33.0 .Run with Custom Image:
docker run --rm \ -v $(pwd)/my-custom-liquibase:/liquibase/changelog \ -e LIQUIBASE_COMMAND_USERNAME="root" \ -e LIQUIBASE_COMMAND_PASSWORD="mysecretpassword" \ my-liquibase-mysql:4.33.0 \ --defaults-file=/liquibase/changelog/liquibase.properties \ updateTip: Pass sensitive
usernameandpasswordvia environment variables (as shown) rather than embedding them directly inliquibase.propertiesthat might be committed to source control.
Exercises
Install Locally + Docker:
- Choose your primary operating system (Windows, macOS, or Linux) and perform a manual installation (or Homebrew on macOS). Verify with
liquibase --version. - Pull the
liquibase/liquibase:4.33.0-alpineDocker image. - Create a directory
my-h2-projectwith achangelog.xml(like the example above, or simpler) and aliquibase.propertiesconfigured for an H2 in-memory database. - Run
liquibase updateusing the Docker image, mounting yourmy-h2-projectdirectory. - Hint: Use the
docker runcommand pattern from section 1.3, making sure to mount your local project directory to/liquibase/changelogin the container.
- Choose your primary operating system (Windows, macOS, or Linux) and perform a manual installation (or Homebrew on macOS). Verify with
Verify Versions:
- Run
liquibase --versionon your local installation. - Run
docker run --rm liquibase/liquibase:4.33.0 --versionto verify the Docker image version.
- Run
No-op Update on H2:
- Using your Docker setup from Exercise 1, run
liquibase status --verboseagainst the H2 in-memory database. What do you expect to see before and after running anupdatewith a new, simple changeset? - Add a new, empty changeset (e.g., just a comment) to your
changelog.xml.<!-- In changelog.xml --> <changeSet id="3" author="yourname"> <comment>This is a no-op changeset for testing.</comment> </changeSet> - Run
liquibase --defaults-file=liquibase.properties updatevia Docker. - Then, run
liquibase --defaults-file=liquibase.properties status --verboseagain. Describe the output change. - Hint: Before the update,
statusshould show the new changeset as “pending”. After, it should show no pending changes.
- Using your Docker setup from Exercise 1, run
Docker Environment Variables:
- Modify your
my-h2-project/liquibase.propertiesto only includechangelog-file: changelog.xmlandlogLevel: INFO. Remove theurl,username,password, anddriverlines. - Run
liquibase updateusing the Docker image, but this time pass theurl,username,password, anddriverparameters entirely via environment variables. - Hint: Refer to the
LIQUIBASE_COMMAND_prefix example in section 1.3.
- Modify your
Simulate MySQL Driver Need:
- Imagine you need to connect to a MySQL database. Create a new directory
my-mysql-projectand inside it, aliquibase.propertiesfile with a MySQL connection URL (you don’t need a live DB for this exercise, just the connection string).url: jdbc:mysql://localhost:3306/mydatabase?useSSL=false driver: com.mysql.cj.jdbc.Driver username: testuser password: testpassword changelog-file: changelog.xml - Create a dummy
changelog.xmlinsidemy-mysql-project(e.g., just a simplecreateTable). - Try to run
liquibase statususing the baseliquibase/liquibase:4.33.0Docker image with this MySQL config (remember to mountmy-mysql-project). What error do you get? - Now, create a
Dockerfileto build a custom image that includes the MySQL JDBC driver (as shown in section 1.4). Build the image (my-liquibase-mysql:4.33.0). - Then, try
liquibase statusagain with your newly built custom Docker image, mountingmy-mysql-project. Does it get past the driver error? What kind of error do you get now (if any)? - Hint: The initial error will likely be
Driver class not found. The second attempt should bypass this, but might hit a “Connection refused” if no MySQL server is running atlocalhost:3306.
- Imagine you need to connect to a MySQL database. Create a new directory
Solutions (Hints)
Install Locally + Docker:
- Local: Follow platform-specific steps.
- Docker:
docker run --rm -v $(pwd)/my-h2-project:/liquibase/changelog liquibase/liquibase:4.33.0-alpine --defaults-file=/liquibase/changelog/liquibase.properties update
Verify Versions:
- Local:
liquibase --version - Docker:
docker run --rm liquibase/liquibase:4.33.0 --version
- Local:
No-op Update on H2:
status --verbosebefore update: Should listchangelog.xml::3::yournameas pending.updatethenstatus --verbose: Should show0 changesets have not been applied...and theDATABASECHANGELOGtable will contain an entry forid=3.
Docker Environment Variables:
- Modified
liquibase.propertiesonly containschangelog-file: changelog.xmlandlogLevel: INFO. - Command:
docker run --rm \ -v $(pwd)/my-h2-project:/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1" \ -e LIQUIBASE_COMMAND_DRIVER="org.h2.Driver" \ -e LIQUIBASE_COMMAND_USERNAME="sa" \ -e LIQUIBASE_COMMAND_PASSWORD="" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="changelog.xml" \ liquibase/liquibase:4.33.0 \ update
- Modified
Simulate MySQL Driver Need:
- Initial run with base image:
Unexpected error running Liquibase: liquibase.exception.DatabaseException: Driver class not found: com.mysql.cj.jdbc.Driver - After building
my-liquibase-mysql:4.33.0with theDockerfilefrom section 1.4:Expected Output: If MySQL server is not running:docker run --rm \ -v $(pwd)/my-mysql-project:/liquibase/changelog \ -e LIQUIBASE_COMMAND_USERNAME="testuser" \ -e LIQUIBASE_COMMAND_PASSWORD="testpassword" \ my-liquibase-mysql:4.33.0 \ --defaults-file=/liquibase/changelog/liquibase.properties \ status
This shows the driver was found, but the connection to the database itself failed, confirming the driver issue is resolved.Unexpected error running Liquibase: liquibase.exception.DatabaseException: com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.
- Initial run with base image:
Pros and cons
liquibase.properties File
- Pros: Centralized configuration for a project, easy to read and understand, suitable for non-sensitive parameters.
- Cons: Not ideal for sensitive credentials (passwords) when committed to version control, can become large for complex setups.
H2 In-Memory Database
- Pros: Extremely fast to start and stop, perfect for isolated testing, no cleanup required (data is lost on exit), excellent for CI environments or local development sandboxes.
- Cons: Data is ephemeral (lost on process exit), not suitable for persistent data storage or production environments.
Common pitfalls and how to avoid them
- Forgetting
DB_CLOSE_DELAY=-1for H2: Without this, the H2 database might shut down prematurely if all connections are closed, leading to errors on subsequent Liquibase commands in the same script/session. - Hardcoding Sensitive Data: Never commit passwords or other sensitive credentials directly into
liquibase.propertiesin version control. Use environment variables (especially in Docker or CI/CD) or a secrets management system. - Incorrect
changelog-filepath: Ensure the path inliquibase.propertiesis correct relative to where Liquibase is executed, or an absolute path. In Docker, ensure it’s relative to the mounted volume’s root (e.g.,/liquibase/changelog/changelog.xml). - Mixing Local and Docker Contexts: Be careful when switching between running Liquibase locally and in Docker. Paths, drivers, and networking can differ. Always ensure your commands and
liquibase.propertiesare adapted for the chosen execution environment.
Troubleshooting and verification steps
- “File Not Found: changelog.xml”: Double-check the
changelog-fileproperty inliquibase.propertiesand the actual location of your changelog. Verify any-vmounts in Docker. - “Unknown host” or “Connection refused”: Verify your database
urlinliquibase.properties. For Docker, ensure proper network configuration (e.g.,host.docker.internalfor host DB, linked containers, or Docker networks). - “Changeset not found” or
statusshows pending changes you expect to be applied:- Check for typos in
idorauthor. - Ensure the
logicalFilePathis consistent (if explicitly set or implied by the changelog path). - Inspect the
DATABASECHANGELOGtable directly to see what Liquibase thinks has been applied.
- Check for typos in
- Verify H2
DATABASECHANGELOGcontents: If you have a tool that can connect to H2 (e.g., DBeaver or even a simple Java app), connect tojdbc:h2:mem:testdbto inspect theDATABASECHANGELOGtable and confirm entries. (This is more advanced for a beginner, but good to know it’s possible).
Takeaway checklist
- Can initialize a basic Liquibase project with
changelog.xmlandliquibase.properties. - Can configure
liquibase.propertiesfor an H2 in-memory database. - Successfully ran
liquibase updateto apply changes. - Used
liquibase statusandliquibase historyto inspect applied changes. - Understood the ephemeral nature of H2 in-memory and the importance of
DB_CLOSE_DELAY=-1. - Aware of secure practices for handling credentials (environment variables).
3) Changelogs and Changesets: SQL/XML/YAML/JSON
What you’ll learn
- The structure and components of a changelog file.
- How to define changesets with
id,author,logicalFilePath,runOnChange, andrunAlways. - Authoring equivalent database changes (e.g.,
createTable,addColumn,sql) across SQL, XML, YAML, and JSON formats. - Understanding the purpose of
sqlFilefor large SQL scripts.
Why it matters
Changelogs and changesets are the heart of Liquibase. Properly structuring them is critical for maintainability, traceability, and ensuring safe, predictable deployments. Choosing the right format depends on team preference and the complexity of changes. Understanding attributes like runOnChange and runAlways is vital for managing procedural changes (like stored procedures or views) versus declarative schema changes.
Concepts explained simply
- Changelog: An ordered list of changes to be applied. It’s the “recipe” for your database schema. The “master” changelog often includes other, smaller changelogs.
- Changeset: The fundamental unit of change within a changelog. Each changeset must be uniquely identified by a combination of
id,author, andlogicalFilePath.id: A unique identifier within that changelog file. Can be numeric, alphanumeric, or descriptive.author: Identifies who created the changeset. Good practice to use initials or team names.logicalFilePath: The path to the changelog file. By default, Liquibase uses the absolute path from the root of the classpath or filesystem. You can override it to ensure portability and uniqueness when changelogs are refactored.- Checksum: Liquibase calculates a checksum for each changeset. If the content of an already-applied changeset changes, the checksum changes, and Liquibase will flag an error to prevent accidental modification of history.
runOnChange: A boolean attribute on a changeset. Iftrue, Liquibase will recalculate the checksum and re-execute the changeset if its content changes. Useful for objects like stored procedures, views, or functions that you want to redeploy if their definition changes, without creating a new changeset every time.runAlways: A boolean attribute on a changeset. Iftrue, Liquibase will execute the changeset every timeupdateis run, regardless of whether its checksum has changed or if it has been applied before. Useful for idempotent scripts like refreshing reference data or re-granting permissions.sqlFile: A tag in XML/YAML/JSON changelogs that points to an external SQL file. This is highly recommended for larger, complex SQL statements or when you want to keep SQL changes in.sqlfiles for database-specific syntax highlighting and tooling.
Multiple examples (show at least two changelog formats when feasible)
We’ll use PostgreSQL as the primary database example.
Example 1: Creating a Table
Let’s create a Products table.
XML Format (changelog-products.xml)
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1-1" author="dev_team">
<comment>Create Products table</comment>
<createTable tableName="products">
<column name="product_id" type="UUID" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="varchar(255)">
<constraints nullable="false" unique="true"/>
</column>
<column name="description" type="text"/>
<column name="price" type="numeric(10, 2)">
<constraints nullable="false"/>
</column>
<column name="created_at" type="timestamp with time zone" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
</databaseChangeLog>
- PostgreSQL Specifics:
UUIDtype,gen_random_uuid()for default,timestamp with time zone.
YAML Format (changelog-products.yaml)
databaseChangeLog:
- changeSet:
id: 1-1
author: dev_team
comment: Create Products table
changes:
- createTable:
tableName: products
columns:
- column:
name: product_id
type: UUID
defaultValueComputed: gen_random_uuid()
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(255)
constraints:
nullable: false
unique: true
- column:
name: description
type: text
- column:
name: price
type: numeric(10, 2)
constraints:
nullable: false
- column:
name: created_at
type: timestamp with time zone
defaultValueComputed: NOW()
constraints:
nullable: false
SQL Format (V001__create_products_table.sql)
Note: Liquibase can execute plain SQL files as changesets directly, often prefixed with a version or descriptive name.
--liquibase changeset dev_team:1-1
--comment: Create Products table
CREATE TABLE products (
product_id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
price NUMERIC(10, 2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
);
- SQL Changelog Specifics: Liquibase looks for special comments (
--liquibase changeset <author>:<id>) at the top of the SQL file to define the changeset.
JSON Format (changelog-products.json)
{
"databaseChangeLog": [
{
"changeSet": {
"id": "1-1",
"author": "dev_team",
"comment": "Create Products table",
"changes": [
{
"createTable": {
"tableName": "products",
"columns": [
{
"column": {
"name": "product_id",
"type": "UUID",
"defaultValueComputed": "gen_random_uuid()",
"constraints": {
"primaryKey": true,
"nullable": false
}
}
},
{
"column": {
"name": "name",
"type": "varchar(255)",
"constraints": {
"nullable": false,
"unique": true
}
}
},
{
"column": {
"name": "description",
"type": "text"
}
},
{
"column": {
"name": "price",
"type": "numeric(10, 2)",
"constraints": {
"nullable": false
}
}
},
{
"column": {
"name": "created_at",
"type": "timestamp with time zone",
"defaultValueComputed": "NOW()",
"constraints": {
"nullable": false
}
}
}
]
}
}
]
}
}
]
}
Example 2: Adding a Column with runOnChange
Let’s add a category column to the Products table and then define a view that automatically updates if its definition changes.
XML Format (changelog-alter-and-view.xml)
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1-2" author="dev_team">
<comment>Add category column to Products</comment>
<addColumn tableName="products">
<column name="category" type="varchar(100)" defaultValue="Uncategorized">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="1-3" author="dev_team" runOnChange="true">
<comment>Create or replace product_summary_view</comment>
<createView viewName="product_summary_view" replaceIfExists="true">
SELECT product_id, name, price, category
FROM products
WHERE price > 0;
</createView>
</changeSet>
</databaseChangeLog>
runOnChange="true"on1-3means if theSELECTstatement changes, Liquibase will detect the checksum change and re-execute this changeset.
YAML Format (changelog-alter-and-view.yaml)
databaseChangeLog:
- changeSet:
id: 1-2
author: dev_team
comment: Add category column to Products
changes:
- addColumn:
tableName: products
columns:
- column:
name: category
type: varchar(100)
defaultValue: Uncategorized
constraints:
nullable: false
- changeSet:
id: 1-3
author: dev_team
runOnChange: true
comment: Create or replace product_summary_view
changes:
- createView:
viewName: product_summary_view
replaceIfExists: true
selectQuery: |
SELECT product_id, name, price, category
FROM products
WHERE price > 0;
SQL Format (V002__alter_products_and_view.sql)
--liquibase changeset dev_team:1-2
--comment: Add category column to Products
ALTER TABLE products
ADD COLUMN category VARCHAR(100) DEFAULT 'Uncategorized' NOT NULL;
--liquibase changeset dev_team:1-3
--comment: Create or replace product_summary_view
--liquibase runOnChange:true
CREATE OR REPLACE VIEW product_summary_view AS
SELECT product_id, name, price, category
FROM products
WHERE price > 0;
--liquibase runOnChange:trueis the directive for SQL formatted changelogs.
Example 3: Inserting Data with runAlways and using sqlFile
Let’s insert some initial product data and then define a stored procedure that might be re-run frequently.
XML Format (changelog-data-and-proc.xml)
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1-4" author="data_init" runAlways="true">
<comment>Initial product data load</comment>
<sqlFile path="sql/initial_products.sql" relativeToChangelogFile="true"/>
<rollback>
<delete tableName="products"/>
</rollback>
</changeSet>
<changeSet id="1-5" author="dev_ops" runOnChange="true">
<comment>Create or replace stored procedure update_product_price</comment>
<sqlFile path="sql/update_product_price_proc.sql" relativeToChangelogFile="true"/>
</changeSet>
</databaseChangeLog>
runAlways="true"on1-4means this data insert will be attempted everyupdaterun. Therollbacktag here is a safety measure.relativeToChangelogFile="true"means thepathis relative to the current changelog file.
sql/initial_products.sql
-- Initial product data
INSERT INTO products (name, description, price, category) VALUES
('Laptop Pro', 'High-performance laptop', 1200.00, 'Electronics'),
('Mechanical Keyboard', 'RGB, tactile switches', 150.00, 'Accessories'),
('Gaming Mouse', 'Ergonomic, high DPI', 75.00, 'Accessories')
ON CONFLICT (name) DO NOTHING; -- Ensure idempotency for runAlways
ON CONFLICT (name) DO NOTHINGis a PostgreSQL specific clause ensuring this script is idempotent. If the rows already exist (due torunAlways), it won’t throw an error. This is crucial forrunAlwayschangesets.
sql/update_product_price_proc.sql (PostgreSQL Stored Procedure)
-- liquibase formatted sql
-- changeset dev_ops:1-5 dbms:postgresql runOnChange:true
-- comment: Stored procedure to update product prices
CREATE OR REPLACE PROCEDURE update_product_price(p_product_id UUID, p_new_price NUMERIC)
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE products
SET price = p_new_price
WHERE product_id = p_product_id;
END;
$$;
- When using
sqlFileand the SQL file itself contains Liquibase directives (--liquibase formatted sql,--changeset), you don’t needid,authorin the parent changelog, only thepathto the SQL file. This gives you finer control over each individual SQL script.
Master Changelog (master.xml or master.yaml)
To tie these examples together, a master changelog would include them:
master.xml
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<include file="changelog-products.xml" relativeToChangelogFile="true"/>
<include file="changelog-alter-and-view.xml" relativeToChangelogFile="true"/>
<include file="changelog-data-and-proc.xml" relativeToChangelogFile="true"/>
<include file="sql/V001__create_products_table.sql" relativeToChangelogFile="true"/>
<include file="sql/V002__alter_products_and_view.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>
includeis used to build a logical changelog from multiple files.
Exercises (5+ per major section)
Assume you have a master.yaml file (or master.xml, your choice) as your main changelog, and you’re working against an H2 in-memory database configured as in Section 2.
Author an
Authorstable (YAML & JSON):- Create a new file
db/authors-schema.yaml. - Define a changeset to create an
Authorstable withid(INT, PK),firstName(VARCHAR),lastName(VARCHAR NOT NULL), anddob(DATE). - Create a parallel file
db/authors-schema.jsonwith the exact same table definition. - Include both files in your
master.yaml(ormaster.xml) at the root level, but ensure only one is active at a time (e.g., by commenting out oneincludestatement). - Run
liquibase updateand verify theAuthorstable is created usingliquibase history. - Hint: Pay attention to column types and constraints syntax for YAML/JSON.
- Create a new file
Add a
Bookstable with a foreign key (XML & SQL):- Create
db/books-schema.xml. - Define a changeset to create a
Bookstable withbook_id(UUID, PK, defaultrandom_uuid()for H2),title(VARCHAR NOT NULL),author_id(INT NOT NULL),publishedDate(DATE). - Add a foreign key constraint on
author_idreferencingAuthors.id. - Create a parallel
db/books-schema.sql(using--liquibase formatted sqlstyle) for the same table and FK. - Again, include and activate only one in your master changelog, run
update, and verify. - Hint: For
random_uuid()in H2, you might need to usedefaultValueComputed="RANDOM_UUID()". TheaddForeignKeyConstrainttag is useful in XML. For SQL, a plainALTER TABLE ADD CONSTRAINTorFOREIGN KEYclause.
- Create
Use
runOnChangefor a simple H2 View:- Create a new changelog file
db/views.xml. - Define a changeset with
id="author-books-view",author="yourname", andrunOnChange="true". - Inside, define a
createViewthat joins theAuthorsandBookstables to showauthor_firstName,author_lastName,book_title,publishedDate. - Include
db/views.xmlin your master. - Run
liquibase update. Verify the view is created. - Now, modify the view’s
SELECTstatement slightly (e.g., add anORDER BYclause). - Run
liquibase updateagain. Observe the output – it should indicate that theauthor-books-viewchangeset was re-executed. - Hint: After the second
update,liquibase historyshould show a newDATEEXECUTEDfor that specific changeset, but itsEXECTYPEshould still beEXECUTED.
- Create a new changelog file
Populate Initial Data with
sqlFileandrunAlways:- Create a directory
db/dataand inside it,db/data/initial-authors.sql. - In
initial-authors.sql, writeINSERTstatements for a few authors (ensure they are idempotent withINSERT IGNOREfor MySQL/H2 orON CONFLICT DO NOTHINGfor Postgres if you were using that).-- For H2: INSERT INTO Authors (id, firstName, lastName, dob) VALUES (1, 'Jane', 'Doe', '1980-01-15'); INSERT INTO Authors (id, firstName, lastName, dob) VALUES (2, 'John', 'Smith', '1975-05-20'); -- Add more if needed, considering idempotency for `runAlways` - Create a new changeset in your
master.yamlwithid="initial-author-data",author="init_script", andrunAlways="true". - Use the
sqlFiletag to point todb/data/initial-authors.sql. - Run
liquibase update. Verify the data is inserted. - Run
liquibase updateagain. What happens? How doesrunAlwaysinteract with idempotency? - Hint: The
runAlwayschangeset will execute every time. Without idempotency in the SQL, subsequent runs would cause errors (e.g., duplicate primary key).
- Create a directory
Refactor SQL Changeset (Advanced):
- Take the
Bookstable creation from Exercise 2 (using the SQL formatdb/books-schema.sql). - Modify your
master.yamlto include this SQL file directly using<include file="db/books-schema.sql" />(not asqlFilewithin an XML/YAML changeset). - Run
liquibase update. Observe the entry inDATABASECHANGELOG. How does Liquibase infer theidandauthorwhen directly including a SQL file with Liquibase directives? - Now, comment out the
--liquibase changesetline insidedb/books-schema.sql. - Modify
master.yamlto instead define an XML/YAML changeset that usessqlFileto referencedb/books-schema.sql. - Run
liquibase update. What checksum error or other issues do you encounter? How would you fix it (e.g.,changelog-sync-forcecautiously, or creating a new changeset)? - Hint: Liquibase infers
idandauthorfrom the SQL comment. Changing an applied changeset’s content (or how it’s defined) will cause a checksum error. This highlights whyid,author,logicalFilePathshould be stable.
- Take the
Solutions (Hints)
Author an
Authorstable:- YAML example:
# db/authors-schema.yaml databaseChangeLog: - changeSet: id: 1 author: yourname changes: - createTable: tableName: authors columns: - column: { name: id, type: INT, constraints: { primaryKey: true, nullable: false } } - column: { name: firstName, type: VARCHAR(100) } - column: { name: lastName, type: VARCHAR(100), constraints: { nullable: false } } - column: { name: dob, type: DATE } - Include in
master.yaml:- include: { file: db/authors-schema.yaml }
- YAML example:
Add a
Bookstable with a foreign key:- XML example:
<!-- db/books-schema.xml --> <changeSet id="1" author="yourname"> <createTable tableName="books"> <column name="book_id" type="UUID" defaultValueComputed="RANDOM_UUID()"> <constraints primaryKey="true" nullable="false"/> </column> <column name="title" type="varchar(255)"> <constraints nullable="false"/> </column> <column name="author_id" type="int"> <constraints nullable="false"/> </column> <column name="publishedDate" type="date"/> </createTable> <addForeignKeyConstraint baseTableName="books" baseColumnNames="author_id" constraintName="fk_book_author" referencedTableName="authors" referencedColumnNames="id"/> </changeSet> - SQL example:
--liquibase changeset yourname:1 CREATE TABLE books ( book_id UUID DEFAULT RANDOM_UUID() PRIMARY KEY, title VARCHAR(255) NOT NULL, author_id INT NOT NULL, publishedDate DATE ); ALTER TABLE books ADD CONSTRAINT fk_book_author FOREIGN KEY (author_id) REFERENCES authors (id);
- XML example:
Use
runOnChangefor a simple H2 View:db/views.xml:<changeSet id="author-books-view" author="yourname" runOnChange="true"> <createView viewName="author_books_view" replaceIfExists="true"> SELECT a.firstName, a.lastName, b.title, b.publishedDate FROM authors a JOIN books b ON a.id = b.author_id ORDER BY a.lastName, b.title; </createView> </changeSet>- After modifying the
SELECTquery, theupdatecommand output will show:ChangeSet db/views.xml::author-books-view::yourname ran successfully in ... (runOnChange).
Populate Initial Data with
sqlFileandrunAlways:- Ensure your
initial-authors.sqlcontains idempotent inserts (e.g.,INSERT IGNOREorMERGEfor H2). - Output of
updateforrunAlways: It will show the changesetEXECUTEDon the first run. On subsequent runs, it will showEXECUTED_AGAIN. TheDATABASECHANGELOGtable will not get new entries, but theDATEEXECUTEDandMD5SUMmight update if Liquibase’s internal tracking changes, but primarily theEXECTYPEshowsEXECUTED_AGAIN.
- Ensure your
Refactor SQL Changeset (Advanced):
- Direct
includewill create an entry likedb/books-schema.sql::1::yourname. - After commenting out directives and using
sqlFilein XML/YAML, Liquibase will calculate the checksum of the new XML/YAML changeset’s content (which includes thesqlFiletag). This will conflict with the existingDATABASECHANGELOGentry fordb/books-schema.sql::1::yournamebecause its checksum was based on the SQL file’s content including the directives. - To fix:
- Bad: Do NOT modify already run changesets or their identifying parameters.
- Cautious (if you know what you’re doing and only on dev): Use
liquibase changelog-sync-forcefollowed byupdate. This tells Liquibase to update the checksum inDATABASECHANGELOGfor that changeset to match the new content. EXTREMELY DANGEROUS IN PRODUCTION! - Best Practice: Create a new changeset that performs the desired change if the original was truly flawed. If refactoring is strictly necessary, consider dropping and recreating the database (in dev), or if already in production, use
changelog-sync-forceonly if the change is cosmetic to the changelog definition and the actual SQL applied is identical.
- Direct
Pros and cons
XML Changelog Format
- Pros: Rich set of built-in change types (
createTable,addColumn,addForeignKeyConstraint, etc.), database-agnostic abstractions (Liquibase translates to specific SQL dialects), strong validation against XSD schema, good tooling support. - Cons: Verbose, can be less readable than SQL for complex DML/DDL, steep learning curve for those unfamiliar with XML.
YAML Changelog Format
- Pros: More concise and human-readable than XML, good balance of abstraction and readability, less verbose, still offers built-in change types and validation.
- Cons: Less mature tooling support than XML for some IDEs, whitespace sensitive.
JSON Changelog Format
- Pros: Similar to YAML in conciseness, good for programmatic generation/parsing, common in modern web development stacks.
- Cons: Can become difficult to read for very deeply nested structures, lacks human readability of YAML/SQL, verbose with curly braces.
SQL Changelog Format (.sql files with directives)
- Pros: Direct SQL, leveraging database-specific features and syntax, familiar to DBAs, easy to integrate with existing SQL scripts, natural for complex DDL/DML.
- Cons: Less database-agnostic (you write for a specific DB), lacks Liquibase’s built-in rollback logic (you must provide it manually), requires special
--liquibasecomments.
runOnChange
- Pros: Ideal for versioning repeatable objects like views, stored procedures, functions without constantly creating new changesets. Simplifies maintenance.
- Cons: If not used carefully, a change might unexpectedly re-execute, potentially causing issues if the script isn’t truly idempotent.
runAlways
- Pros: Useful for idempotent, recurring tasks like ensuring specific reference data exists, granting permissions, or refreshing materialized views.
- Cons: Requires scripts to be perfectly idempotent to avoid errors or unintended side effects on repeated execution. Can hide issues if not monitored.
sqlFile
- Pros: Organizes large SQL statements into separate, manageable files, improves readability of main changelog, allows for database-specific tooling on SQL files.
- Cons: Requires careful path management. If the SQL file itself doesn’t contain Liquibase directives, the containing changeset’s
idandauthorapply to the whole file.
Common pitfalls and how to avoid them
- Modifying an Applied Changeset: NEVER change the
id,author, orlogicalFilePathof an applied changeset, or its content without extreme caution. This leads to checksum errors. If a change is needed, create a new changeset to fix forward. - Non-Idempotent
runAlwaysChangesets: If arunAlwayschangeset tries to insert a row with a duplicate primary key, it will fail. Always useINSERT IGNORE,ON CONFLICT DO NOTHING, orMERGEstatements to make them idempotent. - Forgetting
relativeToChangelogFile="true": When including files or usingsqlFile, ensure paths are correct.relativeToChangelogFile="true"is generally recommended for portable projects. - Duplicate Changeset IDs: The combination of
id,author, andlogicalFilePathmust be unique. Duplicates cause errors. This is especially common when copying changelog fragments without updating IDs. - Database-Specific Syntax in Generic Changelogs: While XML/YAML/JSON are abstracted, complex types or functions still need to be compatible with your target database. Always test thoroughly across all target DBs.
Troubleshooting and verification steps
- Checksum Error (
liquibase.exception.ChangeSetValidationException): This is the most common error. It means the calculated checksum of an already-applied changeset no longer matches what’s recorded inDATABASECHANGELOG.- Diagnosis: Identify the changeset (
id::author::logicalFilePath) causing the error. - Remedy (Development/Testing): If you accidentally changed a development changeset and haven’t deployed it to production,
liquibase --defaults-file=liquibase.properties changelog-sync-force <id::author::logicalFilePath>can update the checksum. Do NOT do this in production unless you fully understand the implications and confirm the change is truly benign and does not alter the actual applied SQL. - Remedy (Production): Never use
changelog-sync-forcelightly in production. The best practice is to create a new changeset to correct the issue or revert the codebase to the state before the invalid change.
- Diagnosis: Identify the changeset (
- “Table/Column Not Found” after
update:- Check
liquibase historyto confirm the changeset was applied. - Check the changelog file for typos in table/column names.
- Verify connectivity to the correct database instance.
- Connect to the database using a client tool and inspect the schema directly.
- Check
runAlwaysorrunOnChangenot behaving as expected:- Double-check the
runAlwaysorrunOnChangeattribute in the changeset definition. - Ensure any embedded SQL is truly idempotent for
runAlways. - For
runOnChangeviews/procs, confirm the change to the SQL query is substantial enough to alter the checksum.
- Double-check the
Takeaway checklist
- Can define changesets with unique
id,author, andlogicalFilePath. - Understands the use cases for
runOnChange(views, procedures) andrunAlways(idempotent data/permissions). - Can create basic schema changes (e.g.,
createTable,addColumn) in XML, YAML, SQL, and JSON. - Knows when and how to use
sqlFilefor external SQL scripts. - Aware of the critical rule: do not modify applied changesets.
- Understands the importance of idempotency for
runAlwayschangesets.
4) Core Commands: update, status, history, diff, diff-changelog, tag, rollback
What you’ll learn
- Mastering the most frequently used Liquibase commands.
- How to apply all pending changes (
update). - Inspecting the current state of changesets (
status,history). - Marking database versions (
tag). - Reverting changes safely (
rollback,rollback-to-tag,rollback-count). - Comparing database schemas and generating new changelogs from differences (
diff,diff-changelog). - Synchronizing changelog and database state (
changelog-sync).
Why it matters
These core commands are your daily toolkit for managing database changes. Proficiency here means you can confidently deploy, inspect, and revert database changes, which is fundamental to safe database DevOps. Understanding diff and diff-changelog is especially powerful for detecting drift and generating migration scripts. Rollbacks are critical safety nets, and knowing their variants is essential for recovery.
Concepts explained simply
Liquibase provides a rich command-line interface (CLI) to interact with your database and manage your changelogs.
update: This is the most common command. It inspects your changelog, compares it to theDATABASECHANGELOGtable, and applies any pending changesets to bring your database schema up to date.status: Shows you which changesets in your changelog have not yet been applied to the target database. Useful for a quick check before runningupdate. Use--verbosefor more detail.history(orchangelogSync): Displays a list of all changesets that have been applied to the database, as recorded in theDATABASECHANGELOGtable.tag <tag_name>: Creates a “tag” in yourDATABASECHANGELOGtable, marking the point in time (or more accurately, the last changeset applied) to which you can later revert. This is like creating a Git tag for your database schema.update-to-tag <tag_name>: Updates the database to the state defined by the changelogs up to a specific tag. If the tag is in the future, it applies changes. If the tag is in the past, it’s equivalent to arollback-to-tag.rollback <tag_name | count | date | changesetId>: Reverts previously applied changes.rollback-to-tag <tag_name>: Reverts all changesets applied after the specified tag.rollback-count <count>: Reverts the specified number of the most recently applied changesets.rollback-to-date <YYYY-MM-DDThh:mm:ss>: Reverts all changesets applied after the specified date/time.rollback <changeset ID>(Liquibase Pro): Allows targeted rollback of a specific changeset by ID.
diff: Compares two database schemas (e.g., source vs. target) and reports the differences. It doesn’t generate changelogs, just lists what’s different.diff-changelog: Compares a database schema against another database schema (or a database against a changelog file) and generates a new changelog file representing the differences. Incredibly useful for baselining or capturing manual changes.changelog-sync: Marks all changesets in the changelog as “executed” in theDATABASECHANGELOGtable without actually running the SQL. Useful for initial baselining or when manually managing a database where changes have already been applied out-of-band.changelog-sync-force: Marks a specific changeset (or all) in the changelog as “executed” in theDATABASECHANGELOGtable and updates its checksum to match the current changelog content. This is highly dangerous in production and should only be used with extreme caution, typically for checksum conflict resolution in development or non-production environments when you are 100% certain the underlying SQL change is correct and safe to re-sync.
Step-by-step lab(s) with copy-pasteable commands and expected output
Let’s use our my-first-liquibase-project setup (from Section 2 and 3) and expand it.
Setup:
Ensure you have my-first-liquibase-project with liquibase.properties pointing to an H2 in-memory database and a master.xml (or master.yaml) that includes:
db/authors-schema.xml(createsAuthorstable).db/books-schema.xml(createsBookstable with FK toAuthors).db/views.xml(createsauthor_books_view). Let’s make sure these changelog files are distinct and simple.
my-first-liquibase-project/master.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"> <include file="db/authors-schema.xml" relativeToChangelogFile="true"/> <include file="db/books-schema.xml" relativeToChangelogFile="true"/> <include file="db/views.xml" relativeToChangelogFile="true"/> </databaseChangeLog>my-first-liquibase-project/db/authors-schema.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"> <changeSet id="1" author="jane_doe"> <createTable tableName="authors"> <column name="id" type="int"> <constraints primaryKey="true" nullable="false"/> </column> <column name="first_name" type="varchar(100)"/> <column name="last_name" type="varchar(100)"> <constraints nullable="false"/> </column> </createTable> </changeSet> </databaseChangeLog>my-first-liquibase-project/db/books-schema.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"> <changeSet id="1" author="jane_doe_books"> <createTable tableName="books"> <column name="book_id" type="int"> <constraints primaryKey="true" nullable="false"/> </column> <column name="title" type="varchar(255)"> <constraints nullable="false"/> </column> <column name="author_id" type="int"> <constraints nullable="false"/> </column> </createTable> <addForeignKeyConstraint baseTableName="books" baseColumnNames="author_id" constraintName="fk_book_author" referencedTableName="authors" referencedColumnNames="id"/> </changeSet> </databaseChangeLog>my-first-liquibase-project/db/views.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"> <changeSet id="1" author="jane_doe_views" runOnChange="true"> <createView viewName="author_books_view" replaceIfExists="true"> SELECT a.first_name, a.last_name, b.title FROM authors a JOIN books b ON a.id = b.author_id; </createView> </changeSet> </databaseChangeLog>
Now, ensure you are in the my-first-liquibase-project directory.
Lab: Core Commands in Action
Initial
updateandhistory: Start with a fresh H2 database (the in-memory nature handles this naturally on each run, but if persistent, you might rundropAllfirst).# Apply all changes liquibase --defaults-file=liquibase.properties updateExpected Output: Shows all 3 changesets being executed.
# View applied changesets liquibase --defaults-file=liquibase.properties historyExpected Output (truncated):
id author dateexecuted orderexecuted exectype md5sum ... ---------- -------------- ---------------- -------------- ---------- ---------- --- 1 jane_doe 2025-10-01 16:00 1 EXECUTED ... .../db/authors-schema.xml 1 jane_doe_books 2025-10-01 16:00 2 EXECUTED ... .../db/books-schema.xml 1 jane_doe_views 2025-10-01 16:00 3 EXECUTED ... .../db/views.xmlstatus:liquibase --defaults-file=liquibase.properties status --verboseExpected Output:
0 changesets have not been applied to testdb@jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1(Because all changes are applied).
taga Release: Let’s mark this state asrelease-1.0.liquibase --defaults-file=liquibase.properties tag release-1.0Expected Output:
Liquibase 4.33.0 by Liquibase Successfully acquired change log lock Successfully released change log lock 'release-1.0' was added to the database.Verify in
history(thetagcommand adds an entry toDATABASECHANGELOG).Add a new changeset and
update: Create a new filedb/data.xmlto insert some data.my-first-liquibase-project/db/data.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"> <changeSet id="1" author="data_loader"> <insert tableName="authors"> <column name="id" valueNumeric="1"/> <column name="first_name" value="Stephen"/> <column name="last_name" value="King"/> </insert> <insert tableName="books"> <column name="book_id" valueNumeric="101"/> <column name="title" value="It"/> <column name="author_id" valueNumeric="1"/> </insert> <insert tableName="books"> <column name="book_id" valueNumeric="102"/> <column name="title" value="The Shining"/> <column name="author_id" valueNumeric="1"/> </insert> </changeSet> </databaseChangeLog>
Now, include
db/data.xmlinmaster.xml:<!-- In master.xml --> <include file="db/data.xml" relativeToChangelogFile="true"/>Run
updateagain:liquibase --defaults-file=liquibase.properties updateExpected Output: Shows
db/data.xml::1::data_loaderchangeset being executed.Check
statusandhistoryagain. You’ll see the new changeset applied.rollback-to-tag: Let’s roll back torelease-1.0before the data was inserted.liquibase --defaults-file=liquibase.properties rollback-to-tag release-1.0Expected Output:
... Successfully acquired change log lock Rolling back ChangeSet: db/data.xml::1::data_loader Table AUTHORS rolled back. Table BOOKS rolled back. ... Successfully released change log lock Rollback successful for 1 changesets.Note: For
insertoperations, Liquibase’s auto-rollback often just deletes rows. For DDL likecreateTable, it might issue adropTable. Always inspect auto-generated rollbacks or explicitly define them.Verify with
history. The data insertion changeset should be gone.diffanddiff-changelog(against an empty “scratch” database): This is best demonstrated by creating a temporary, empty database for comparison.Create a “Scratch” H2 properties file (
scratch.properties):# scratch.properties url: jdbc:h2:mem:scratchdb;DB_CLOSE_DELAY=-1 driver: org.h2.Driver username: sa password:Populate our primary database to a known state (e.g., re-run all updates after rollback, then remove the data changeset from
master.xmlto illustrate capturing manual changes). For this lab, let’s say our main H2 (testdb) is atrelease-1.0(authors, books, views schema only).Run
diffto see schema differences from scratch:# Compare testdb (current schema) to scratchdb (empty schema) liquibase --defaults-file=liquibase.properties \ --reference-url="jdbc:h2:mem:scratchdb;DB_CLOSE_DELAY=-1" \ --reference-username="sa" \ --reference-password="" \ diffExpected Output (truncated, will be verbose):
Reference Database: jdbc:h2:mem:scratchdb;DB_CLOSE_DELAY=-1 Comparison Database: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 Differences: - Missing Table: PUBLIC.AUTHORS - Missing Table: PUBLIC.BOOKS - Missing Table: PUBLIC.DATABASECHANGELOG - Missing Table: PUBLIC.DATABASECHANGELOGLOCK - Missing View: PUBLIC.AUTHOR_BOOKS_VIEW - Missing Foreign Key: PUBLIC.FK_BOOK_AUTHOR - Missing Column: PUBLIC.AUTHORS.FIRST_NAME ... (many more differences)This shows that
testdbhas many objects thatscratchdb(the reference) doesn’t.Run
diff-changelogto generate changes to makescratchdbmatchtestdb:liquibase --defaults-file=liquibase.properties \ --reference-url="jdbc:h2:mem:scratchdb;DB_CLOSE_DELAY=-1" \ --reference-username="sa" \ --reference-password="" \ diff-changelog \ --output-file=generated-changelog.xmlExpected Output: A new file
generated-changelog.xmlwill be created containing all the DDL to createAuthors,Books,author_books_view, and Liquibase’s internal tables inscratchdb. This is extremely powerful for baselining or capturing manual changes.
Exercises (5+ per major section)
Assume your master.xml includes db/authors-schema.xml, db/books-schema.xml, and db/views.xml.
Initial Setup and
status:- Ensure your H2
testdbis clean (restart the Liquibase process to clear in-memory H2, or usedropAllif persistent). - Run
liquibase updatewith yourmaster.xml. - Run
liquibase status --verbose. What is the output?
- Ensure your H2
Tagging and
update-to-tag:- After the
updatefrom Exercise 1, tag the database state asbaseline_schema. - Add a new changelog file
db/add_email_to_authors.xmlwith a changeset to add anemailcolumn (varchar(255),unique=true) to theauthorstable. - Include this new file in your
master.xml. - Run
liquibase update. - Now, use
liquibase update-to-tag baseline_schema. What happens? (Hint:update-to-tagacts like arollback-to-tagif the tag is in the past). Verify withhistory.
- After the
rollback-countandfix-forward:- Add two new, simple changesets to a new file
db/data_updates.xml:- Changeset 1: Insert 3 new authors into the
authorstable. - Changeset 2: Add a new
last_logincolumn (timestamp) to theauthorstable.
- Changeset 1: Insert 3 new authors into the
- Include
db/data_updates.xmlinmaster.xmland runupdate. - Realize you made a mistake with the
last_logincolumn and want to revert only the last two changesets. Userollback-count 2. - Verify with
history. Did it remove both the data and the column? - Now, instead of rolling back, imagine you want to “fix forward”. Add a new changeset to
db/data_updates.xmlthat drops thelast_logincolumn (if it exists) and then re-adds it with aDEFAULT NOW()constraint. Rerunupdate. (This illustrates fixing forward without touching previous history).
- Add two new, simple changesets to a new file
difffor Drift Detection:- Ensure your database is at the state before the
emailcolumn was added toauthors(i.e., roll backdb/add_email_to_authors.xml). - Manually add the
emailcolumn to theauthorstable directly in the database using a SQL client tool (outside Liquibase).-- Example for H2 or PostgreSQL ALTER TABLE authors ADD COLUMN email VARCHAR(255); - Now run
liquibase diffcomparing your current changelog (master.xml) against the modified database. What differences does it report? - Hint: It should report that your database has an
emailcolumn that is “Unexpected”. Your changelog doesn’t know about it. This is database drift.
- Ensure your database is at the state before the
diff-changelogfor Capturing Manual Changes:- Starting from the drift scenario in Exercise 4 (database has
emailcolumn, changelog doesn’t). - Run
liquibase diff-changelog --output-file=drift-fix.xml. - Inspect
drift-fix.xml. What does it contain? Does it correctly capture theaddColumnforemail? - Include
drift-fix.xmlin yourmaster.xml. - Run
liquibase update. What happens? Does Liquibase attempt to apply theemailcolumn again? Why/why not? - Hint:
diff-changeloggenerates a new changeset to add the column. If the column already exists, Liquibase might error or skip based on preconditions if added (covered in next section). For now, it might error onaddColumnif the column is already there.
- Starting from the drift scenario in Exercise 4 (database has
Solutions (Hints)
Initial Setup and
status:updateoutput: all 3 changesets executed.status --verboseoutput:0 changesets have not been applied...
Tagging and
update-to-tag:tag baseline_schema.db/add_email_to_authors.xml:<changeSet id="1" author="new_feature"> <addColumn tableName="authors"> <column name="email" type="varchar(255)"> <constraints unique="true"/> </column> </addColumn> </changeSet>update(applies email column).update-to-tag baseline_schema: Liquibase will roll back thedb/add_email_to_authors.xmlchangeset.historywill show it removed.
rollback-countandfix-forward:db/data_updates.xml:<changeSet id="1" author="data_loader_2"> <insert ... authors ...> </changeSet> <changeSet id="2" author="column_mistake"> <addColumn tableName="authors"> <column name="last_login" type="timestamp"/> </addColumn> </changeSet>update.rollback-count 2: Removes both changesets.- Fix-forward: Remove
id="2" author="column_mistake"changeset. Add new changeset todb/data_updates.xml:<changeSet id="3" author="column_fix"> <dropColumn tableName="authors" columnName="last_login" remarks="Fixing previous mistake"/> <addColumn tableName="authors"> <column name="last_login" type="timestamp" defaultValueComputed="NOW()"/> </addColumn> </changeSet> update: Applies the fix-forward changeset.
difffor Drift Detection:- After manual
ALTER TABLE authors ADD COLUMN email VARCHAR(255); liquibase diffwill report:
(and potentially other related objects like indexes if you added them).- Unexpected Column: PUBLIC.AUTHORS.EMAIL
- After manual
diff-changelogfor Capturing Manual Changes:drift-fix.xmlwill contain a new changeset:<changeSet author="generated" id="1701388800000-1"> <!-- id will be timestamp-based --> <addColumn tableName="authors"> <column name="email" type="VARCHAR(255)"/> </addColumn> </changeSet>- Including this in
master.xmland runningupdatewill likely result in an error likeDuplicate column name 'EMAIL'because Liquibase tries to add it again, but it already exists from your manual change. This highlights thatdiff-changeloggenerates creation changes, and if the object already exists, you need to handle it. In the next section (Preconditions), we’ll see how to guard against this. For now, you might need to manually remove the column from the DB before applying, or simply observe the error.
Pros and cons
update
- Pros: Simplest way to bring the database up to the latest state defined by changelogs.
- Cons: Can be dangerous if run on production without proper review and safety checks.
status / history
- Pros: Essential for understanding the current state of a database relative to the changelog.
- Cons:
statusonly shows pending changes.historycan be long for large projects.
tag
- Pros: Creates stable points for rollbacks, great for marking releases or critical deployment milestones.
- Cons: Relies on the changelog being consistent and correctly representing the schema at that point.
rollback (all variants)
- Pros: Critical for disaster recovery and undoing faulty deployments.
- Cons: Can lead to data loss if not carefully planned or if rollback statements are not explicitly defined. OSS rollbacks can be coarse (
count,to-tag,to-date).
diff
- Pros: Excellent for quick schema comparisons, detecting unplanned changes (drift).
- Cons: Only reports differences, doesn’t generate remediation scripts. Output can be verbose.
diff-changelog
- Pros: Powerful for generating initial baselines, capturing manual changes, or comparing different environments to generate migration scripts. Saves immense manual effort.
- Cons: Generated changesets are often generic. Requires review and customization, especially for data migrations or complex refactorings. Can generate changes for Liquibase’s own
DATABASECHANGELOGtables.
changelog-sync / changelog-sync-force
- Pros (
changelog-sync): Useful for onboarding an existing database without applying changes again (marks changes as run). - Cons (
changelog-sync-force): Highly dangerous in production. Directly manipulatesDATABASECHANGELOGchecksums. Use only as a last resort in controlled development environments for specific checksum conflicts.
Common pitfalls and how to avoid them
- Rollback Data Loss:
rollbackforinsertstatements usually performsdelete. If theinsertalso had updates to existing data, a simpledeletewon’t restore the original state. Always define explicitrollbackblocks for complex data changes. - Running
updateon Production without Review: Always useupdateSQLto generate the SQL script first, review it, and then runupdateorupdateSQL | psqlin a controlled manner for production. - Misinterpreting
diffvs.diff-changelog:diffis for reporting;diff-changelogis for generating changelogs. Don’t usediffif you need a script to apply. - Forgetting to
tag: Untagged releases make targeted rollbacks much harder. Tag consistently! changelog-sync-forceAbuse: This is an emergency tool. If you find yourself using it often, it indicates a deeper problem in your change management process (e.g., manual changes, unversioned changes, or improper changelog modifications).
Troubleshooting and verification steps
- “Rollback failed: No inverse statement for…”: Liquibase couldn’t automatically generate a rollback for a specific change (e.g.,
dropTable, complex SQL). You need to add an explicit<rollback>block to that changeset. - Unexpected
diffresults:- Verify you’re comparing the correct databases/schemas.
- Ensure connection parameters are correct for both source and reference.
- Check for case sensitivity issues (some DBs are case-sensitive, Liquibase might normalize).
diff-changeloggenerates too much (or too little):- Use
includeObjectsorexcludeObjectsparameters withdiff-changelogto filter generated output. - Ensure the source and reference databases are at the expected state.
- Use
tagnot visible inhistory: Ensure thetagcommand executed successfully. Check theDATABASECHANGELOGtable directly.
Takeaway checklist
- Can confidently use
updateto apply changes. - Can inspect database state using
statusandhistory. - Can create a
tagfor a release. - Can perform basic rollbacks using
rollback-to-tagandrollback-count. - Can use
diffto detect schema differences. - Can generate a changelog from schema differences using
diff-changelog. - Understands the extreme caution required for
changelog-sync-force.
5) Preconditions, Contexts, Labels, and Parameter Substitution
What you’ll learn
- How to use
preconditionsto guard changesets and ensure safe execution. - Applying changes conditionally with
contextsfor environment-specific deployments. - Using
labelsfor flexible changeset filtering duringupdateandrollbackoperations. - Parameter substitution for dynamic values in changelogs.
Why it matters
These advanced control mechanisms elevate your Liquibase usage from simply applying changes to intelligently and safely managing complex deployment scenarios. Preconditions prevent errors by verifying the database state before executing sensitive changes. Contexts and labels enable a single changelog to serve multiple environments or deployment strategies, promoting “immutable infrastructure” principles for your database. Parameter substitution allows for dynamic configuration without hardcoding values.
Concepts explained simply
Imagine your database deployments needing “if-then” logic: “IF the table exists, THEN add a column. ELSE create the table.” Or, “Only run this data script IF the environment is ‘dev’.” These are the problems preconditions, contexts, and labels solve.
- Preconditions: These are checks Liquibase performs before executing a changeset. If a precondition fails, Liquibase can be configured to stop, skip the changeset, or warn. They are crucial for idempotency and guarding against unexpected database states.
- Examples:
tableExists,columnExists,sqlCheck(execute arbitrary SQL and check result),changeSetExecuted. - You define
onFailandonFailMessage(e.g.,HALT,CONTINUE,MARK_RAN).
- Examples:
- Contexts: A way to categorize changesets for environment-specific deployments. You assign one or more contexts to a changeset. When running Liquibase, you specify which contexts are active (e.g.,
--contexts=dev). Only changesets matching an active context (or with no context specified) will be run.- Example:
createTableindevcontext only.
- Example:
- Labels: Similar to contexts but more flexible. Labels are arbitrary tags you can attach to changesets. You can use
--labels=prod_releaseto apply only changesets with that label, or--labels=bugfix AND high_priorityfor more complex filtering. They are not tied to environments necessarily, but can be used for features, releases, priority, etc. - Parameter Substitution (Properties): Liquibase allows you to use placeholders in your changelogs (e.g.,
${tableName}). These placeholders are replaced by values defined in yourliquibase.propertiesfile, environment variables, or passed directly on the command line. This is excellent for making changelogs reusable and environment-agnostic.- Syntax:
value=${property.name}or within SQLCREATE TABLE ${schemaName}.my_table.
- Syntax:
Multiple examples (show at least two changelog formats when feasible)
We’ll continue with PostgreSQL examples.
Example 1: Preconditions - tableExists and sqlCheck
Let’s create a Users table, but only if it doesn’t already exist. Then, add a unique index only if the table has at least 100 users.
XML Format (changelog-users.xml)
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="security_team">
<preConditions onFail="MARK_RAN" onFailMessage="Users table already exists, marking as ran.">
<not>
<tableExists tableName="users"/>
</not>
</preConditions>
<comment>Create Users table</comment>
<createTable tableName="users">
<column name="id" type="UUID" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="username" type="varchar(100)">
<constraints nullable="false" unique="true"/>
</column>
<column name="email" type="varchar(255)">
<constraints nullable="false" unique="true"/>
</column>
<column name="created_at" type="timestamp with time zone" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet id="2" author="security_team">
<preConditions onFail="CONTINUE" onFailMessage="Less than 100 users, skipping unique index on email.">
<sqlCheck expectedResult="true">
SELECT COUNT(*) >= 100 FROM users;
</sqlCheck>
</preConditions>
<comment>Add unique index on email (if >= 100 users)</comment>
<createIndex tableName="users" indexName="idx_users_email_unique" unique="true">
<column name="email"/>
</createIndex>
</changeSet>
</databaseChangeLog>
onFail="MARK_RAN": Ifuserstable exists, Liquibase records this changeset asEXECUTEDwithout running it. Good for idempotent “create if not exists” logic.onFail="CONTINUE": IfsqlCheckfails, Liquibase skips this changeset but continues with subsequent ones.
YAML Format (changelog-users.yaml)
databaseChangeLog:
- changeSet:
id: 1
author: security_team
preConditions:
- onFail: MARK_RAN
onFailMessage: Users table already exists, marking as ran.
not:
- tableExists:
tableName: users
changes:
- createTable:
tableName: users
columns:
- column: { name: id, type: UUID, defaultValueComputed: "gen_random_uuid()", constraints: { primaryKey: true, nullable: false } }
- column: { name: username, type: varchar(100), constraints: { nullable: false, unique: true } }
- column: { name: email, type: varchar(255), constraints: { nullable: false, unique: true } }
- column: { name: created_at, type: timestamp with time zone, defaultValueComputed: "NOW()", constraints: { nullable: false } }
- changeSet:
id: 2
author: security_team
preConditions:
- onFail: CONTINUE
onFailMessage: Less than 100 users, skipping unique index on email.
sqlCheck: SELECT COUNT(*) >= 100 FROM users;
changes:
- createIndex:
tableName: users
indexName: idx_users_email_unique
unique: true
columns:
- column: { name: email }
Example 2: Contexts - Environment-Specific Changes
Let’s add some audit columns that are only relevant for prod and staging environments, and a specific test user for dev.
XML Format (changelog-audit.xml)
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="prod_ops" contexts="prod, staging">
<comment>Add audit columns to Users table for prod/staging</comment>
<addColumn tableName="users">
<column name="last_modified_by" type="varchar(100)"/>
<column name="last_modified_at" type="timestamp with time zone" defaultValueComputed="NOW()"/>
</addColumn>
</changeSet>
<changeSet id="2" author="dev_data" contexts="dev">
<comment>Insert test user for development environments</comment>
<insert tableName="users">
<column name="id" value="a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"/>
<column name="username" value="testuser"/>
<column name="email" value="test@example.com"/>
<column name="created_at" valueComputed="NOW()"/>
</insert>
</changeSet>
</databaseChangeLog>
contexts="prod, staging": This changeset will only run if eitherprodorstagingcontext is active.contexts="dev": This changeset will only run ifdevcontext is active.
To run update for dev:
liquibase --defaults-file=liquibase.properties --contexts=dev update
To run update for prod:
liquibase --defaults-file=liquibase.properties --contexts=prod update
- Changesets without an explicit
contextsattribute will run regardless of the active context(s).
Example 3: Labels - Feature-based Rollback
Let’s add a new feature that involves two changesets, tagging them with a feature-x label.
YAML Format (changelog-feature-x.yaml)
databaseChangeLog:
- changeSet:
id: 1
author: feature_team
labels: feature-x, beta
comment: Create FeatureX table
changes:
- createTable:
tableName: feature_x
columns:
- column: { name: id, type: UUID, defaultValueComputed: "gen_random_uuid()", constraints: { primaryKey: true, nullable: false } }
- column: { name: feature_name, type: varchar(255) }
- changeSet:
id: 2
author: feature_team
labels: feature-x, beta
comment: Add data to FeatureX table
changes:
- insert:
tableName: feature_x
columns:
- column: { name: id, value: "b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" }
- column: { name: feature_name, value: "Initial Beta Feature" }
To update specific features (e.g., only feature-x changes):
liquibase --defaults-file=liquibase.properties --labels=feature-x update
To rollback only the beta labeled changes (useful if a beta feature has issues but you don’t want to revert everything):
liquibase --defaults-file=liquibase.properties --labels=beta rollback-count 1000 # Rollback all with label, adjust count
# Or more precisely with Liquibase Pro:
# liquibase --defaults-file=liquibase.properties --labels=beta rollback-one-changeset <id::author::filepath>
- Note:
rollback-count 1000with labels will try to rollback the most recent changesets that also match the label, up to the count. It’s not a selective “only these labels” rollback for the basic commands. Targeted rollbacks (Pro feature) offer more precise control.
Example 4: Parameter Substitution
Let’s use a parameter for schema name and table prefixes.
liquibase.properties
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
driver: org.h2.Driver
username: sa
password:
changelog-file: master.xml
schemaName: public # Default schema for H2/Postgres
tablePrefix: app_
XML Format (changelog-parameterized.xml)
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="architect">
<comment>Create a parameterized lookup table</comment>
<createTable schemaName="${schemaName}" tableName="${tablePrefix}lookup">
<column name="id" type="int">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="code" type="varchar(50)">
<constraints nullable="false" unique="true"/>
</column>
<column name="description" type="varchar(255)"/>
</createTable>
</changeSet>
<changeSet id="2" author="architect">
<comment>Insert into parameterized table</comment>
<sql>
INSERT INTO ${schemaName}.${tablePrefix}lookup (id, code, description) VALUES
(1, 'ACTIVE', 'Active Status'),
(2, 'INACTIVE', 'Inactive Status');
</sql>
</changeSet>
</databaseChangeLog>
To run, include this in master.xml and simply run update:
liquibase --defaults-file=liquibase.properties update
This will create public.app_lookup.
You can override parameters on the command line:
liquibase --defaults-file=liquibase.properties -DschemaName=admin -DtablePrefix=sys_ update
This would create admin.sys_lookup (assuming admin schema exists). Command line parameters always take precedence over liquibase.properties.
Exercises (5+ per major section)
Assume you have a master.xml and an H2 in-memory database setup.
Guarding with
tableExistsPrecondition:- Create
db/product_settings.xml. - Define a changeset
id="1", author="admin_setup"that creates aproduct_settingstable (e.g.,id INT PK,setting_name VARCHAR,setting_value VARCHAR). - Add a
preConditionsblock to this changeset that ensures the table is not existing before creation, usingonFail="MARK_RAN". - Include
db/product_settings.xmlin yourmaster.xml. - Run
liquibase update. Verify the table is created. - Run
liquibase updateagain. What is the output concerning this changeset? Why? - Hint: The
MARK_RANbehavior is key.
- Create
Environment-Specific Data with
contexts:- Create
db/initial_data.xml. - Define two changesets:
id="1", author="data_seed", contexts="dev": Inserts a singledefault_dev_productinto theproductstable.id="2", author="data_seed", contexts="qa": Inserts a set of 5qa_test_product_Nentries into theproductstable.
- Include
db/initial_data.xmlinmaster.xml. - Run
liquibase update --contexts=dev. Verify only the dev product is inserted (you’ll need to query the H2 DB manually, or add a Liquibasesqlchange to select from it for verification). - Clear the H2 database (restart Liquibase process for in-memory H2).
- Run
liquibase update --contexts=qa. Verify only the QA products are inserted. - Hint: Use
insertchange type. Remember to clear the DB between runs to properly test contexts.
- Create
Conditional Index Creation with
sqlCheck:- Add a new column
last_activity_date(DATE) to youruserstable (from a previous exercise) if it doesn’t already exist. - Create a new changeset
id="2", author="performance_team"indb/users.xmlthat creates an indexidx_users_activityonusers.last_activity_date. - Add a
preConditionsblock to this index creation changeset usingsqlCheckto only create the index if theuserstable has more than 1000 rows (SELECT COUNT(*) FROM users;and check for> 1000). UseonFail="CONTINUE". - Run
liquibase update. What happens to the index? (It should be skipped if no data is inserted). - Manually insert 1001 rows into the
userstable using a SQL client or a Liquibasesqlchangeset without a context. - Run
liquibase updateagain. Now what happens to the index? - Hint: The
sqlCheckwill evaluate the count. For manually inserting, ensure youridcolumn has values.
- Add a new column
Flexible Rollback with
labels(andupdate-to-tag):- Add two new changesets to a new file
db/new_feature.xml:id="1", author="feature_dev",labels="new_search_feature": Creates asearch_indextable.id="2", author="feature_dev",labels="new_search_feature, data_migration": Populates some initial data intosearch_index.
- Include
db/new_feature.xmlinmaster.xml. - Run
liquibase update. - Tag the current state as
pre_search_deploy. - Run
liquibase update --labels=new_search_feature. (This should apply nothing as the changesets are already run). - Now, try to use
rollbackwith--labels. For example, simulate a rollback of just thenew_search_feature. Useliquibase update-to-tag pre_search_deploy --labels=new_search_feature. Does it work as expected for basic rollbacks, or does it rollback all changes to the tag? Explain the interaction. - Hint: Standard
rollback-to-tagrolls back all changesets after the tag, regardless of label. Labels are primarily for forward application. Targeted rollback (Pro) offers label-based undo. Observe the limitation of OSS here.
- Add two new changesets to a new file
Parameterizing Table and Column Names:
- Modify your
db/books-schema.xml(from earlier exercises) to use parameters for thebookstable name and theauthor_idcolumn name.tableName="${bookTable}"column name="${authorIdColumn}"
- Update
liquibase.propertiesto define:bookTable: books authorIdColumn: author_id - Run
liquibase update. Verify it works as before. - Now, pass different values for
bookTableandauthorIdColumnon the command line (e.g.,my_books,writer_id) and observe the impact. - Hint: You can pass
-DbookTable=my_booksdirectly to theliquibasecommand.
- Modify your
Solutions (Hints)
Guarding with
tableExistsPrecondition:db/product_settings.xml:<changeSet id="1" author="admin_setup"> <preConditions onFail="MARK_RAN" onFailMessage="Product settings table already exists, marking as ran."> <not> <tableExists tableName="product_settings"/> </not> </preConditions> <createTable tableName="product_settings"> <column name="id" type="int"> <constraints primaryKey="true" nullable="false"/> </column> <column name="setting_name" type="varchar(255)"> <constraints nullable="false" unique="true"/> </column> <column name="setting_value" type="varchar(255)"/> </createTable> </changeSet>- First
update: Table created, changeset executed. - Second
update: Output will showLiquibase 'update' successful (marked as ran). TheMARK_RANcondition ensured it didn’t error.
Environment-Specific Data with
contexts:db/initial_data.xml:<changeSet id="1" author="data_seed" contexts="dev"> <insert tableName="products"> <column name="product_id" value="c0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"/> <column name="name" value="Dev Test Product"/> <column name="price" valueNumeric="9.99"/> <column name="category" value="Test"/> </insert> </changeSet> <changeSet id="2" author="data_seed" contexts="qa"> <!-- 5 inserts for QA products --> </changeSet>- Verify with
SELECT * FROM products;(e.g., usingliquibase sqlcommand if H2 is still active).
Conditional Index Creation with
sqlCheck:- Add
last_activity_datetouserstable. db/users.xml(new changeset):<changeSet id="2" author="performance_team"> <preConditions onFail="CONTINUE" onFailMessage="Less than 1000 users, skipping activity index."> <sqlCheck expectedResult="true">SELECT COUNT(*) > 1000 FROM users;</sqlCheck> </preConditions> <createIndex tableName="users" indexName="idx_users_activity"> <column name="last_activity_date"/> </createIndex> </changeSet>- First
update(with <1000 users): Index skipped. - After inserting 1001 users manually, second
update: Index created.
- Add
Flexible Rollback with
labels:db/new_feature.xml:<changeSet id="1" author="feature_dev" labels="new_search_feature"> <createTable tableName="search_index">...</createTable> </changeSet> <changeSet id="2" author="feature_dev" labels="new_search_feature, data_migration"> <insert tableName="search_index">...</insert> </changeSet>updateapplies both.tag pre_search_deploy.update --labels=new_search_featuredoes nothing new.rollback-to-tag pre_search_deploy --labels=new_search_feature: The standardrollback-to-tagcommand (OSS) does not filter by labels for the rollback itself. It rolls back all changesets applied afterpre_search_deploy, regardless of their labels. The--labelsparameter onrollbackonly affects which changesets could have been applied if you were applying forward (which isn’t whatrollback-to-tagis doing, it’s operating on history). This illustrates a limitation of OSS rollbacks versus Pro’s targeted rollbacks.
Parameterizing Table and Column Names:
db/books-schema.xml:<createTable tableName="${bookTable}"> ... <column name="${authorIdColumn}" type="int"> <constraints nullable="false"/> </column> ... </createTable>liquibase.properties:bookTable: books,authorIdColumn: author_id.- Command line override:
liquibase --defaults-file=liquibase.properties -DbookTable=my_books -DauthorIdColumn=writer_id update.historywould show changes applied tomy_bookstable.
Pros and cons
Preconditions
- Pros: Enhances safety and idempotency, prevents errors on already-existing objects, allows for complex conditional logic, supports different failure handling (HALT, CONTINUE, MARK_RAN).
- Cons: Can add verbosity to changelogs, requires careful definition to avoid unintended skips or halts.
Contexts
- Pros: Enables a single, unified changelog to be used across multiple environments, promoting “one artifact” principle, simplifies CI/CD pipelines.
- Cons: Can lead to “context bloat” if overused, requires discipline to manage contexts, default behavior (no context specified runs everything) needs to be understood.
Labels
- Pros: Highly flexible for filtering changesets by feature, priority, team, etc., supports complex logical expressions (
AND,OR), powerful for selective deployment or analysis. - Cons: Standard rollback commands don’t filter by labels. More advanced label-based rollback requires Liquibase Pro’s targeted rollback.
Parameter Substitution
- Pros: Makes changelogs reusable and environment-agnostic, avoids hardcoding values, supports various sources for parameters (properties file, environment variables, CLI).
- Cons: Can make changelogs harder to read if heavily parameterized, requires careful management of parameter values across environments.
Common pitfalls and how to avoid them
- Misunderstanding
onFailoptions:MARK_RANis powerful but can hide actual errors if the precondition truly indicates a problem. Use it for expected “already exists” scenarios.HALTis safest for critical, mandatory conditions. - Forgetting default
contextsbehavior: If you specify--contexts=dev, onlydevchangesets and those without any context will run. Changesets withcontexts="prod"will be skipped. Be explicit. - Overuse of Labels/Contexts: While flexible, too many can make changelogs complex and difficult to manage. Keep them focused and meaningful.
- Parameter Name Conflicts: If a parameter is defined in multiple places (properties file, environment variable, CLI), the CLI takes precedence, then environment, then properties. Understand this order of precedence.
- SQL in
sqlCheckPreconditions: Be mindful of database-specific SQL syntax withinsqlCheck. It needs to be valid for your target DB.
Troubleshooting and verification steps
- Precondition unexpected behavior:
- Increase
logLeveltoDEBUGinliquibase.propertiesto see detailed precondition evaluation. - Temporarily remove the
preConditionsblock and run the changeset to confirm the underlying SQL works as expected. - Run
sqlCheckSQL manually against the database to confirm its result.
- Increase
- Contexts/Labels not filtering:
- Verify the spelling of your contexts/labels in both the changelog and the CLI command.
- Check for changesets that have no
contextsattribute – they always run. - Use
liquibase status --verbose --contexts=<your_context>to see which changesets are deemed pending under that specific context.
- Parameter not substituted:
- Check for typos in
${parameterName}. - Ensure the parameter is defined in
liquibase.properties, as an environment variable (e.g.,LIQUIBASE_SQL_SCHEMAPREFIX), or on the command line (-DpropertyName=value). - Verify precedence rules if the parameter is defined in multiple places.
- Check for typos in
Takeaway checklist
- Can add
preConditionsto changesets to ensure safe execution. - Understands how to use
contextsfor environment-specific deployments. - Can leverage
labelsfor flexible changeset filtering (primarily for forward deployment). - Can use parameter substitution in changelogs for dynamic values.
- Understands the different
onFailbehaviors for preconditions. - Aware of the order of precedence for parameter values.
6) Organizing and Scaling Changelogs (modular structure, includes, naming, IDs)
What you’ll learn
- Strategies for structuring changelogs in a modular and scalable way.
- Effective use of
<include>and<includeAll>tags. - Best practices for naming changelog files and changesets (
id,author,logicalFilePath). - Techniques to prevent changeset ID collisions in team environments.
- How to evolve changelog structure over time.
Why it matters
As your project grows, a single monolithic changelog file becomes unmanageable. A well-organized, modular changelog structure is crucial for team collaboration, code readability, reducing merge conflicts, and improving deployment reliability. Proper naming conventions and ID management are key to preventing checksum errors and ensuring the uniqueness that Liquibase relies on.
Concepts explained simply
Think of your master changelog as the root of a tree, and individual feature or module changelogs as branches or leaves.
- Modular Structure: Instead of one giant changelog, break it down into smaller, logical files. This can be by:
- Feature:
features/user-management.xml,features/product-catalog.xml - Module/Service:
service-a/changelog.xml,service-b/changelog.xml - Date/Version:
2025/q1-release.xml,2025/q2-bugfixes.xml
- Feature:
<include file="..." />: Explicitly includes a single changelog file into the current changelog. Thefileattribute is usually relative to the current changelog or the classpath.relativeToChangelogFile="true"is highly recommended for portability.<includeAll path="..." />: Includes all changelog files found in a specified directory. Liquibase will apply them in a consistent, alphabetical order by filename. This is powerful for automatically picking up new changelogs without explicitincludestatements.- Caveat: Since order is alphabetical by filename, you need a robust naming convention for your files (e.g.,
V001__description.sql,20250101_initial_schema.xml).
- Caveat: Since order is alphabetical by filename, you need a robust naming convention for your files (e.g.,
id,author,logicalFilePath: These three attributes uniquely identify a changeset.logicalFilePath: This is critical for modular changelogs. By default, Liquibase uses the absolute path to the changelog file in the filesystem or classpath. If you refactor your file system (e.g., move a changelog file), thelogicalFilePathchanges, leading to checksum errors. Explicitly settinglogicalFilePathin the changeset (e.g.,logicalFilePath="db/master.xml") or by usingrelativeToChangelogFile="true"onincludestatements is essential. The best practice is to makelogicalFilePathstable by setting it to the path of the master changelog that includes it, or a logical path that never changes regardless of physical file location.
- Naming Conventions: Crucial for
includeAlland team collaboration.- Changelog Files: Often prefixed with a version number or date (e.g.,
V001_create_tables.xml,22250101-01_add_index.sql). - Changeset
id: Can be incremental numbers (e.g.,1,2,3) within a single file, UUIDs, or descriptive (e.g.,create-user-table). Using incremental numbers within a small, self-contained file (likedb/authors-schema.xml) is common. - Changeset
author: Your team ID, initials, or JIRA ticket ID (e.g.,dev_team,jsmith,PROJ-123).
- Changelog Files: Often prefixed with a version number or date (e.g.,
Step-by-step lab(s) with copy-pasteable commands and expected output
Let’s restructure our project.
Initial Project Structure (from previous sections):
my-liquibase-project/
├── liquibase.properties
├── master.xml
├── db/
│ ├── authors-schema.xml
│ ├── books-schema.xml
│ ├── views.xml
│ └── data.xml
└── sql/ # Example for sqlFile
└── update_product_price_proc.sql
Goal: Refactor to a more modular structure using includeAll and clearer naming.
Restructure Directories and Rename Files:
cd my-liquibase-project mkdir -p changes/schemas/authors mkdir -p changes/schemas/books mkdir -p changes/schemas/views mkdir -p changes/data mkdir -p changes/procsMove and rename:
mv db/authors-schema.xml changes/schemas/authors/V001__create_authors_table.xml mv db/books-schema.xml changes/schemas/books/V001__create_books_table.xml mv db/views.xml changes/schemas/views/V001__create_author_books_view.xml mv db/data.xml changes/data/R001__initial_test_data.xml # 'R' for repeatable/reference data mv sql/update_product_price_proc.sql changes/procs/R001__update_product_price_proc.sql- Notice the
V001__andR001__prefixes, making them sort alphabetically forincludeAll. - The
changesfolder will be our new logical root for changes.
- Notice the
Update
master.xmlto useincludeAll:<!-- my-liquibase-project/master.xml --> <?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"> <!-- Include all schema changes. Order matters, so prefixed V001_ --> <includeAll path="changes/schemas" relativeToChangelogFile="true"/> <!-- Include all data changes (often marked as runAlways) --> <includeAll path="changes/data" relativeToChangelogFile="true"/> <!-- Include all stored procedures/views (often runOnChange) --> <includeAll path="changes/procs" relativeToChangelogFile="true"/> </databaseChangeLog>Update
liquibase.properties: Ensurechangelog-file: master.xmlis correct.Crucially: Update
logicalFilePath(if needed) or rely onrelativeToChangelogFile:- If you haven’t run
updatebefore, the newlogicalFilePath(based on theincludeAllandfilerelative paths) will be picked up. - If these changesets were already applied under their old paths, Liquibase will complain about checksum errors due to
logicalFilePathchanges. - Best practice for new modularization:
- Start with a fresh database (drop/recreate or for H2, just restart).
- Or, if you must keep history, you’d use
changelog-sync-forcefor each affected changeset after careful review.
- For this lab, let’s assume a fresh H2 database.
- If you haven’t run
Run
updatewith the new structure:liquibase --defaults-file=liquibase.properties updateExpected Output: All changesets from
authors,books,views,data, andprocsshould be applied in alphabetical order as determined byincludeAll. ThelogicalFilePathinDATABASECHANGELOGwill reflect the new paths frommaster.xml’s perspective (e.g.,changes/schemas/authors/V001__create_authors_table.xml).
Exercises (5+ per major section)
Assume you start with a clean H2 database and a my-modular-project with liquibase.properties and a master.xml that uses includeAll pointing to a db folder.
Modularize by Feature:
- Create a
dbdirectory. - Inside
db, create subdirectories:auth,products,orders. - In
auth/V001_create_users_table.xml, define a changeset to create auserstable. - In
products/V001_create_products_table.xml, define a changeset to create aproductstable. - In
orders/V001_create_orders_table.xml, define a changeset to create anorderstable. - Your
master.xmlshould use<includeAll path="db" relativeToChangelogFile="true"/>. - Run
liquibase update. Verify thatusers,products, andorderstables are created. Checkhistoryfor thelogicalFilePathentries. - Hint: The
includeAllcommand automatically scans subdirectories unless specified otherwise.
- Create a
Add a new feature and use
includefor fine control:- Add a new directory
db/features/coupons. - Inside
coupons, createV001_create_coupons_table.xmlto define acouponstable. - Modify
master.xmlto explicitly<include file="db/features/coupons/V001_create_coupons_table.xml" relativeToChangelogFile="true"/>after the mainincludeAllfordb. - Run
liquibase update. What is the difference in behavior and logging compared to simply addingdb/featurestoincludeAll? Why might you chooseincludeoverincludeAllfor new features? - Hint:
includeallows you to control the exact order of execution if needed, whereasincludeAllrelies on alphabetical order.
- Add a new directory
Preventing ID Collisions (Team Scenario):
- Imagine two developers,
dev_aanddev_b, are working on different features, both adding new tables. dev_acreatesdb/dev_a/V001_create_blog_posts.xmlwithid="1" author="dev_a".dev_bcreatesdb/dev_b/V001_create_comments.xmlwithid="1" author="dev_b".- Modify
master.xmlto include bothdb/dev_aanddb/dev_busing<includeAll>(e.g.,<includeAll path="db/dev_a" relativeToChangelogFile="true"/>and similarly fordev_b). - Run
liquibase update. Do you get a conflict? Why or why not? - Hint: Changeset uniqueness is
id+author+logicalFilePath.
- Imagine two developers,
Refactoring and
logicalFilePathChallenge:- Take one of your existing changelogs, say
db/auth/V001_create_users_table.xml. - Run
liquibase history. Note itslogicalFilePath. - Now, without using
includeAll, modify yourmaster.xmlto explicitly include it as<include file="auth_features/users/initial_schema.xml" relativeToChangelogFile="true"/>after moving the physical file todb/auth_features/users/initial_schema.xml. - Attempt to run
liquibase update. What error do you get? - How to Fix (Development Environment Safe): Revert the file move. Add
logicalFilePath="db/auth/V001_create_users_table.xml"to thechangeSettag within the XML file. Then perform the file move and updatemaster.xml. Rerunupdate. What happens now? - Hint: The
logicalFilePathstored inDATABASECHANGELOGmust match the one Liquibase calculates (or the one you explicitly set). Changing physical path changes the calculatedlogicalFilePathby default.
- Take one of your existing changelogs, say
Using
logicalFilePathfor Cross-DB Reusability:- Create a generic changelog
shared/create_audit_table.xmlthat creates a genericaudit_logtable. - Include this
shared/create_audit_table.xmlin yourmaster.xml. - Run
liquibase update. Note thelogicalFilePathinhistory. - Now, assume you want to include this same
create_audit_table.xmlin another, completely separate Liquibase project’smaster.xml, but you want bothDATABASECHANGELOGtables (for each project) to consider it the same changeset, even though it’s physically in two different project directories. - Modify
shared/create_audit_table.xmlby addinglogicalFilePath="global/audit/schema.xml"to thechangeSettag. - In both
master.xmlfiles for your separate projects, include this modified file. - Run
updateon both. Observe thelogicalFilePathin eachDATABASECHANGELOGtable. - Hint: Explicitly setting
logicalFilePathhelps standardize change identification regardless of physical location.
- Create a generic changelog
Solutions (Hints)
Modularize by Feature:
- Structure:
db/auth,db/products,db/orders. master.xml:<includeAll path="db" relativeToChangelogFile="true"/>updatewill execute changesets inauth,orders,productsalphabetically.
- Structure:
Add a new feature and use
include:master.xmlwill have both<includeAll path="db" .../>and<include file="db/features/coupons/V001_create_coupons_table.xml" .../>.- The
includestatement ensures that thecouponstable is created exactly when that line is encountered inmaster.xml’s processing, regardless of its alphabetical order relative to other files indbifincludeAllwere used. Useful for strict ordering requirements.
Preventing ID Collisions (Team Scenario):
db/dev_a/V001_create_blog_posts.xml:id="1" author="dev_a"db/dev_b/V001_create_comments.xml:id="1" author="dev_b"- Running
updatewill not cause a conflict! The full changeset identifier isid::author::logicalFilePath. Since thelogicalFilePathwill be different fordev_a/V001_create_blog_posts.xmlanddev_b/V001_create_comments.xml, Liquibase sees them as unique changesets, even though they share the sameidandauthor(ifdev_aanddev_bhappened to use the same author name). This highlights whylogicalFilePathis so important for uniqueness.
Refactoring and
logicalFilePathChallenge:- Moving
db/auth/V001_create_users_table.xmltodb/auth_features/users/initial_schema.xmland updatingmaster.xmlwithout fixinglogicalFilePathwill cause a checksum error:liquibase.exception.ChangeSetValidationException: Validation Error: The file ... has changed since it was run...because thelogicalFilePathinDATABASECHANGELOGno longer matches the one Liquibase calculates for the moved file. - Fix: Edit
V001_create_users_table.xml(before moving) to addlogicalFilePath="db/auth/V001_create_users_table.xml". Then move it. This makes thelogicalFilePathstable regardless of where the file sits physically. Liquibase will then match the explicitlogicalFilePathto the one in the database and succeed.
- Moving
Using
logicalFilePathfor Cross-DB Reusability:shared/create_audit_table.xml:<changeSet id="1" author="global_team" logicalFilePath="global/audit/schema.xml">- In
ProjectA/master.xml:<include file="shared/create_audit_table.xml" relativeToChangelogFile="true"/> - In
ProjectB/master.xml:<include file="path/to/shared/create_audit_table.xml" relativeToChangelogFile="true"/> - Both projects’
DATABASECHANGELOGtables will record the changeset as1::global_team::global/audit/schema.xml, ensuring it’s treated as the same unique change, even if physically located in different paths relative to their respectivemaster.xmlfiles.
Pros and cons
Modular Changelog Structure
- Pros: Improved readability, easier team collaboration, reduced merge conflicts, better organization for large projects, ability to manage domain-specific changes separately.
- Cons: Requires careful planning and consistent adherence to naming conventions.
<include> vs. <includeAll>
<include>:- Pros: Explicit control over changeset execution order, useful for specific dependencies, allows for mixing different file types in specific order.
- Cons: Requires manual updates to
master.xmlfor every new changelog, can be tedious for many files.
<includeAll>:- Pros: Automatically picks up new changelog files, reduces maintenance of
master.xml, great for standardizing directory-based modules. - Cons: Relies heavily on filename sorting for execution order (requires robust naming conventions), harder to insert specific files out of order, less explicit control.
- Pros: Automatically picks up new changelog files, reduces maintenance of
logicalFilePath Management
- Pros: Ensures changeset uniqueness and stability even if physical files are moved or included from different locations.
- Cons: Requires forethought; if not set explicitly, refactoring can lead to checksum errors.
Common pitfalls and how to avoid them
- Unplanned
includeAllOrder: If not using a strict naming convention (e.g.,V<number>__description),includeAllmight apply changesets in an unpredictable order, leading to errors. UseVorRprefixes for versioned/repeatable. - Refactoring File Paths without
logicalFilePath: Moving a changelog file will change its defaultlogicalFilePathinDATABASECHANGELOG, causing checksum validation errors on subsequent runs. Always setlogicalFilePathexplicitly if you anticipate file moves or shared changelogs. - Deeply Nested
includeAll: While possible, deep nesting can make it hard to reason about the overall execution order. Keep structure relatively flat. - Changeset ID Collisions (when authors/paths are the same): Even with good practices, collisions are possible. The
idshould be unique within thelogicalFilePath. Combined withauthor, this forms the unique identifier. - Mixing Formats in
includeAll:includeAllworks fine with mixed formats. However, ensuring consistent content and directives within those files is important.
Troubleshooting and verification steps
- “Unexpected error running Liquibase: Validation Error: The file … has changed since it was run”: This is often a
logicalFilePathmismatch.- Verify
logicalFilePath: Check theFILENAMEcolumn in yourDATABASECHANGELOGtable. Does it match what Liquibase is now expecting (based on yourincludeorincludeAllpaths and any explicitlogicalFilePathset)? - Remedy (Dev): If the change is simply a file path refactor and the content is truly the same, update the
logicalFilePathin theDATABASECHANGELOGtable manually, or usechangelog-sync-forcevery carefully. - Remedy (Production): Avoid this situation by planning
logicalFilePathexplicitly from the start, especially for shared or critical changelogs.
- Verify
- Changesets not running (or running in wrong order) with
includeAll:- Check your filename prefixes. Are they alphabetical and sequential?
- Run
liquibase list-nested-changelogs(a diagnostic command) to see the effective order Liquibase will process your changelog files.
- Duplicate Changeset Errors: Double-check
idandauthorwithin the changelog file causing the error. Ensure it’s unique combined with itslogicalFilePath.
Takeaway checklist
- Changelogs are organized into a logical, modular directory structure.
- Can effectively use
<include>for explicit control and<includeAll>for automatic inclusion. - Filenames are consistently named (e.g.,
V###__description.xml) forincludeAll. - Understands the critical role of
logicalFilePathfor changeset uniqueness and stability. - Aware of potential ID collisions and how
id,author,logicalFilePathcombine for uniqueness. - Can troubleshoot
logicalFilePathmismatches.
7) Rollbacks Deep Dive: rollback, rollback-one-changeset, targeted rollback (Pro), fix-forward strategies
What you’ll learn
- Understanding the different types of rollbacks in Liquibase (OSS vs. Pro).
- Authoring explicit
rollbackblocks within changesets for safety. - Applying rollbacks using
rollback-count,rollback-to-date, androllback-to-tag. - Introducing Liquibase Pro’s
rollback-one-changesetand targeted rollback capabilities. - Embracing the “fix-forward” mindset as a primary strategy.
- Safe practices for DDL and data migrations during rollbacks.
Why it matters
Rollbacks are a critical safety net in database deployments. While the ideal is to never need one, being able to reliably revert changes is paramount for disaster recovery, correcting errors, and maintaining data integrity. Understanding both Liquibase’s auto-generated rollbacks and how to define explicit, data-safe rollbacks is a mark of an expert practitioner. The “fix-forward” strategy, where you commit new changesets to correct issues, often minimizes risk compared to directly undoing applied changes.
Concepts explained simply
Rollbacks aren’t just “undoing” things. They are planned actions.
- Auto-generated Rollbacks: For many built-in change types (e.g.,
createTable,addColumn), Liquibase can automatically generate an inverse operation (e.g.,dropTable,dropColumn). This is convenient but not always safe or sufficient, especially for data migrations or complex DDL. - Explicit
rollbackBlocks: You can (and should for complex or data-sensitive changes) define a<rollback>tag within your changeset. This contains the SQL or Liquibase change types to execute when that changeset is rolled back. This gives you precise control, including data recovery logic. - OSS Rollback Commands:
rollback-count <count>: Reverts the lastcountchangesets, in reverse order of application.rollback-to-date <YYYY-MM-DDThh:mm:ss>: Reverts all changesets applied after the specified timestamp.rollback-to-tag <tag_name>: Reverts all changesets applied after the changeset marked by thetag_name.- Limitation: OSS rollbacks are sequential and affect a block of changes. They cannot selectively pick individual changesets from the middle of the history without rolling back everything above them.
- Liquibase Pro Targeted Rollbacks:
rollback-one-changeset <changeset_id>: (Pro only) Allows you to roll back a specific changeset by itsid::author::logicalFilePathwithout affecting subsequent changesets. This is incredibly powerful for surgical corrections.rollback-one-update <id>: (Pro only) Rolls back all changesets that were part of a specificupdateoperation.rollback-one-change <changeset_id>(Pro only) This is for a single change tag within a changeset, allowing even finer granularity.- Pro Advantage: Offers precise, non-sequential rollbacks, crucial for complex production environments.
- Fix-Forward Strategy: Instead of rolling back a problematic changeset, you create new changesets that correct the issue.
- When to use: Almost always preferred for production environments. It maintains a linear history, avoids potential data loss from rollbacks, and is less disruptive.
- Example: If you added a column with the wrong type, a fix-forward approach would be a new changeset that alters the column type (if possible without data loss) or migrates data to a new column.
- Reversible DDL and Data Migrations:
- DDL: Plan DDL changes to be reversible. E.g., adding a nullable column, then filling it, then making it non-nullable. Dropping columns or tables is inherently destructive and should be handled with extreme caution and explicit data backup/restore in rollbacks.
- Data Migrations: If a changeset modifies data (e.g., updates values), the
rollbackblock should contain SQL that restores the original data or a safe default. This might involve creating temporary tables to store old data.
Step-by-step lab(s) with copy-pasteable commands and expected output
Let’s use a simplified master.xml and H2 setup.
Setup:
my-rollback-project/liquibase.properties (H2 in-memory).
my-rollback-project/master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="dev_team">
<comment>Create Customers table</comment>
<createTable tableName="customers">
<column name="id" type="int"> <constraints primaryKey="true" nullable="false"/> </column>
<column name="name" type="varchar(255)"> <constraints nullable="false"/> </column>
</createTable>
<rollback>
<dropTable tableName="customers"/>
</rollback>
</changeSet>
<changeSet id="2" author="dev_team">
<comment>Add email column to Customers (auto-rollbackable)</comment>
<addColumn tableName="customers">
<column name="email" type="varchar(255)"/>
</addColumn>
</changeSet>
<changeSet id="3" author="dev_team">
<comment>Insert initial customer data (explicit rollback)</comment>
<insert tableName="customers">
<column name="id" valueNumeric="1"/>
<column name="name" value="Alice Smith"/>
<column name="email" value="alice@example.com"/>
</insert>
<insert tableName="customers">
<column name="id" valueNumeric="2"/>
<column name="name" value="Bob Johnson"/>
<column name="email" value="bob@example.com"/>
</insert>
<rollback>
<!-- Explicit rollback for data: delete the inserted rows -->
<delete tableName="customers">
<where>id IN (1, 2)</where>
</delete>
</rollback>
</changeSet>
<changeSet id="4" author="dev_team">
<comment>Add status column with default (explicit rollback)</comment>
<addColumn tableName="customers">
<column name="status" type="varchar(50)" defaultValue="ACTIVE"/>
</addColumn>
<rollback>
<!-- Rolling back a column addition can lose data if not careful -->
<!-- But in this case, a simple drop is safe as no existing data needed conversion -->
<dropColumn tableName="customers" columnName="status"/>
</rollback>
</changeSet>
</databaseChangeLog>
Lab: Rollback Operations
Initial
update:liquibase --defaults-file=liquibase.properties updateExpected Output: All 4 changesets executed.
historycheck:liquibase --defaults-file=liquibase.properties historyExpected Output: All 4 changesets listed as EXECUTED.
rollback-count 1(rolling back ‘Add status column’):liquibase --defaults-file=liquibase.properties rollback-count 1Expected Output:
... Rolling back ChangeSet: master.xml::4::dev_team ALTER TABLE PUBLIC.CUSTOMERS DROP COLUMN STATUS Rollback successful for 1 changesets.Verify with
history. Changeset 4 should be gone.rollback-count 2(rolling back ‘Insert initial customer data’ and ‘Add email column’):liquibase --defaults-file=liquibase.properties rollback-count 2Expected Output:
... Rolling back ChangeSet: master.xml::3::dev_team DELETE FROM PUBLIC.CUSTOMERS WHERE ID IN (1, 2) Rolling back ChangeSet: master.xml::2::dev_team ALTER TABLE PUBLIC.CUSTOMERS DROP COLUMN EMAIL Rollback successful for 2 changesets.Note: ChangeSet 3 used its explicit rollback (DELETE). ChangeSet 2 used auto-rollback (DROP COLUMN).
Verify with
history. Changesets 2, 3, 4 should be gone. Only 1 remains.tagandrollback-to-tag: Clean the database (restart H2 for fresh start), then re-runupdate.liquibase --defaults-file=liquibase.properties update liquibase --defaults-file=liquibase.properties tag V1.0_InitialSchemaNow, add a new changeset (e.g., adding an
addresscolumn tocustomersin a new filemaster_v1.1.xmlwhich is included by master or useaddColumndirectly in master.xml):<!-- Add this to master.xml after existing changesets --> <changeSet id="5" author="dev_team_v1.1"> <comment>Add address column</comment> <addColumn tableName="customers"> <column name="address" type="varchar(500)"/> </addColumn> </changeSet>Run
update:liquibase --defaults-file=liquibase.properties updateNow,
rollback-to-tag:liquibase --defaults-file=liquibase.properties rollback-to-tag V1.0_InitialSchemaExpected Output: Should roll back ChangeSet 5.
rollback-to-date: Clean database,updateeverything. Note the current timestamp or choose a date/time after ChangeSet 3 but before ChangeSet 4.# Example: to rollback everything after 16:30 on 2025-10-01 liquibase --defaults-file=liquibase.properties rollback-to-date 2025-10-01T16:30:00This will rollback any changesets that executed after that specific date/time.
Simulate a failed deploy and fix-forward:
- Clean database,
updateup to ChangeSet 3. - Imagine ChangeSet 4 was flawed (e.g.,
statuscolumn was added asINTinstead ofVARCHAR, or the default value was wrong). - Instead of rolling back ChangeSet 4, we’ll fix-forward. Add a new changeset:
<!-- New changeset to fix the 'status' column in master.xml --> <changeSet id="5_fix" author="dev_team_fix"> <comment>Fix: Re-add status column with correct type and default</comment> <!-- First, clean up the bad column if it exists. Use precondition for safety. --> <preConditions onFail="MARK_RAN" onFailMessage="Status column not found, skipping drop."> <columnExists tableName="customers" columnName="status"/> </preConditions> <dropColumn tableName="customers" columnName="status"/> <!-- Now add it correctly --> <addColumn tableName="customers"> <column name="status" type="varchar(50)" defaultValue="ACTIVE_V2"/> </addColumn> <rollback> <dropColumn tableName="customers" columnName="status"/> </rollback> </changeSet> - Run
liquibase --defaults-file=liquibase.properties update. - The
dropColumnandaddColumnin5_fixwill execute. Your history remains linear, and the problematic ChangeSet 4 is preserved as a historical record of what was attempted.
- Clean database,
Exercises (5+ per major section)
You’re working on a new e-commerce project with an H2 database.
Explicit Rollback for
createTable(YAML):- Create
db/products_v1.yamlwith a changeset (id="1", author="ecom_dev") to create aproductstable (columns:id UUID PK,name VARCHAR NOT NULL,price NUMERIC(10,2)). - Add an explicit
rollbackblock that drops theproductstable. - Include this in
master.xmland runliquibase update. - Run
liquibase rollback-count 1. Verify the table is dropped.
- Create
Data Migration with Explicit Rollback:
- Re-create the
productstable from Exercise 1. - Add a new changeset (
id="2", author="ecom_dev") todb/products_v1.yamlthat:- Inserts 3 sample products.
- Has an explicit
rollbackblock that deletes these specific 3 products using their IDs.
- Run
liquibase update. Verify data is inserted. - Run
liquibase rollback-count 1. Verify only those 3 products are deleted, leaving the table structure intact.
- Re-create the
Simulate a Bug and Fix-Forward for a Column Type:
- Start with
productstable (from Ex. 1). - Add a changeset (
id="3", author="ecom_dev") todb/products_v1.yamlthat adds astock_quantitycolumn ofVARCHAR(10)type (a mistake). - Run
liquibase update. - Realize the mistake. Instead of rolling back changeset 3:
- Add a new changeset (
id="4", author="ecom_dev_fix") that drops thestock_quantitycolumn (usingpreConditionsto check for its existence for safety) and then re-adds it with the correctINTtype,defaultValue=0, andnullable=false.
- Add a new changeset (
- Run
liquibase update. Verifystock_quantityis nowINT. Explain why fix-forward was better here than rollback. - Hint: Dropping a column is auto-rollbackable by default, but manually fixing in a new changeset demonstrates the fix-forward principle.
- Start with
Targeted Rollback (Pro Feature - Conceptual Exercise):
- Imagine you have a complex changelog with many changesets applied in sequence, and you identify a single, non-critical changeset (e.g., adding a specific index) applied deep in the history that is causing a minor performance regression. You want to revert just this one index without affecting any subsequent changes.
- Question: How would you use Liquibase Pro’s
rollback-one-changesetcommand? - Question: What are the key advantages of this over OSS rollback commands in this scenario?
- Hint: You’ll need the full
id::author::logicalFilePathidentifier.
Rollback for Data Transformation:
- Add a new changeset (
id="5", author="ecom_dev") todb/products_v1.yamlthat:- Adds a temporary column
old_name(VARCHAR(255)) toproducts. - Copies
nameintoold_name. - Updates
nameto uppercase (e.g.,UPDATE products SET name = UPPER(name);). - Drops the
old_namecolumn.
- Adds a temporary column
- Define a robust explicit
rollbackblock for this changeset that ensures if it’s rolled back, thenamecolumn is restored to its original (pre-uppercase) values. This will require the temporaryold_namecolumn to be restored during rollback to hold the original values. - Run
liquibase update. - Run
liquibase rollback-count 1. Verify thenamecolumn has its original casing restored. - Hint: The rollback block for this type of transformation is intricate. It would need to re-add
old_name, copyold_nameback toname, then dropold_name.
- Add a new changeset (
Solutions (Hints)
Explicit Rollback for
createTable:db/products_v1.yaml:databaseChangeLog: - changeSet: id: 1 author: ecom_dev changes: - createTable: tableName: products columns: - column: { name: id, type: UUID, constraints: { primaryKey: true, nullable: false } } - column: { name: name, type: VARCHAR(255), constraints: { nullable: false } } - column: { name: price, type: NUMERIC(10,2) } rollback: - dropTable: { tableName: products }
Data Migration with Explicit Rollback:
db/products_v1.yaml(add to existing):- changeSet: id: 2 author: ecom_dev changes: - insert: { tableName: products, columns: [{ name: id, value: "uuid1" }, { name: name, value: "Laptop" }, { name: price, value: 1200 }] } - insert: { tableName: products, columns: [{ name: id, value: "uuid2" }, { name: name, value: "Mouse" }, { name: price, value: 50 }] } - insert: { tableName: products, columns: [{ name: id, value: "uuid3" }, { name: name, value: "Keyboard" }, { name: price, value: 100 }] } rollback: - delete: { tableName: products, where: "id IN ('uuid1', 'uuid2', 'uuid3')" }
Simulate a Bug and Fix-Forward for a Column Type:
- Initial bad changeset 3:
addColumn: { tableName: products, column: { name: stock_quantity, type: VARCHAR(10) } } - Fix-forward changeset 4:
<changeSet id="4" author="ecom_dev_fix"> <preConditions onFail="MARK_RAN"> <columnExists tableName="products" columnName="stock_quantity"/> </preConditions> <dropColumn tableName="products" columnName="stock_quantity"/> <addColumn tableName="products"> <column name="stock_quantity" type="INT" defaultValueNumeric="0"> <constraints nullable="false"/> </column> </addColumn> <rollback> <dropColumn tableName="products" columnName="stock_quantity"/> </rollback> </changeSet> - Fix-forward is better because it leaves the history of the “mistake” (changeSet 3) intact, and you’re not undoing applied changes that might have downstream dependencies. It also simplifies troubleshooting by showing a clear remediation path.
- Initial bad changeset 3:
Targeted Rollback (Pro Feature - Conceptual Exercise):
- Command:
liquibase --defaults-file=liquibase.properties rollback-one-changeset <id>::<author>::<logicalFilePath> - Advantages over OSS:
- Precision: Only that one specific changeset is rolled back, leaving all subsequent changesets (which might be critical for application functionality) untouched.
- Reduced Risk: Avoids the “cascading rollback” effect of OSS commands, which could revert perfectly good changes.
- Efficiency: Faster recovery from isolated issues without needing full application redeployments or lengthy downtime.
- Command:
Rollback for Data Transformation:
- This is tricky! The rollback needs to reverse the steps carefully.
- Changeset 5:
<changeSet id="5" author="ecom_dev"> <comment>Transform product names to uppercase</comment> <addColumn tableName="products"> <column name="temp_old_name" type="VARCHAR(255)"/> </addColumn> <sql>UPDATE products SET temp_old_name = name;</sql> <sql>UPDATE products SET name = UPPER(name);</sql> <dropColumn tableName="products" columnName="temp_old_name"/> <rollback> <!-- Restore temp_old_name --> <addColumn tableName="products"> <column name="temp_old_name" type="VARCHAR(255)"/> </addColumn> <!-- Assume we captured the original names somewhere, or have a snapshot --> <!-- For a real scenario, this would involve more sophisticated backup/restore. --> <!-- Simplistic example: assume the temp_old_name could be restored somehow --> <!-- In a robust rollback, temp_old_name would need to be populated from a snapshot --> <!-- For this exercise, let's make the rollback *re-add* temp_old_name and restore from it --> <sql>UPDATE products SET name = temp_old_name;</sql> <dropColumn tableName="products" columnName="temp_old_name"/> </rollback> </changeSet> - Crucial Note: The
rollbackblock here is simplified. In a real-world scenario, you would likely need to store the original state of thenamecolumn in a separate backup table or a temporary column that persists during the rollback process (which this setup might not guarantee if the transaction scope is small). The most robust data rollback often involves taking a full backup before the transform, and restoring from that if a rollback is needed. This exercise highlights the complexity. A truly robust rollback for a data transform would require capturing original data before the update and restoring it. Therollbackblock often needs to re-introduce temporary columns, populate them, then perform the inverse transformation, and finally drop the temporary columns. This is why fix-forward is often preferred.
Pros and cons
Auto-generated Rollbacks
- Pros: Convenient, no manual effort for simple DDL operations (e.g.,
createTable->dropTable). - Cons: Dangerous for complex DDL or data migrations, can lead to data loss, might not always generate the “correct” or desired inverse operation.
Explicit rollback Blocks
- Pros: Full control over how a change is reverted, crucial for data safety and complex DDL, allows for custom logic (e.g., data restoration).
- Cons: Requires manual effort to define, can be complex to write correctly, prone to errors if not thoroughly tested.
OSS Rollback Commands (rollback-count, rollback-to-date, rollback-to-tag)
- Pros: Standard rollback mechanisms, easy to understand how they operate on history, available in OSS.
- Cons: Coarse-grained – they always revert a sequential block of changes. Cannot perform surgical rollbacks in the middle of history without affecting subsequent changes.
Liquibase Pro Targeted Rollbacks (rollback-one-changeset, etc.)
- Pros: Highly precise, non-sequential rollbacks, minimal impact on other changesets, crucial for advanced DevOps and production incident management, reduces downtime.
- Cons: Requires Liquibase Pro license.
Fix-Forward Strategy
- Pros: Recommended approach for production environments, maintains linear history, reduces risk of data loss, less disruptive than rollbacks (you’re applying new changes, not undoing old ones), easier to audit.
- Cons: Can sometimes result in a “messy” changelog history with many corrective changesets, but this is often preferable to a broken database.
Common pitfalls and how to avoid them
- Trusting Auto-generated Rollbacks for Data: Never rely on auto-rollbacks for changes that involve data manipulation or complex DDL. Always define explicit rollbacks.
- Insufficient Explicit Rollbacks: Rollback blocks must completely reverse the intended effect, including data, constraints, and indexes. Test your rollbacks thoroughly.
- Rollbacks Causing Data Loss: This is the biggest risk. Plan
rollbackblocks to restore data or notify if data loss is unavoidable. Consider database snapshots as a pre-rollback measure. - Using
rollback-countin Production without Care: A high count might revert too many changes. Always dry-run withrollbackSQLand carefully selectcountortag. - Misunderstanding
rollback-one-changeset: Only available in Pro. Attempting this with OSS commands will yield errors or unintended full rollbacks.
Troubleshooting and verification steps
- “No inverse statement for…” error during rollback: This means Liquibase couldn’t auto-generate a rollback, and you didn’t provide an explicit
<rollback>block. You must add one. - Rollback completes but database state is wrong:
- Inspect your explicit
rollbackblock. Is its logic correct? - Verify data using SQL queries after the rollback.
- Check
DATABASECHANGELOGto ensure the correct changesets were removed.
- Inspect your explicit
rollback-countrolls back too much/too little:- Use
liquibase historyto count the changesets you expect to revert. - Always use
liquibase rollbackSQL --count <X>to see the generated SQL before executing the actual rollback.
- Use
rollback-to-tagrolls back to the wrong point:- Verify the tag name is correct.
- Check
liquibase historyto ensure the tag exists and is at the expected location.
Takeaway checklist
- Understands when to use auto-generated vs. explicit
rollbackblocks. - Can define explicit
rollbackblocks for DDL and data changes. - Proficiently uses
rollback-count,rollback-to-date,rollback-to-tag. - Understands the benefits and limitations of OSS vs. Pro rollbacks.
- Embraces “fix-forward” as the primary strategy for production issue resolution.
- Aware of data loss risks during rollbacks and strategies to mitigate them.
8) Drift Detection and Diffing (OSS vs Pro capabilities)
What you’ll learn
- The concept of database drift and why it’s a problem.
- Using
diffanddiff-changelogcommands in Liquibase OSS for basic drift detection. - Advanced drift detection features available in Liquibase Pro.
- Strategies for handling and remediating noisy diffs and detected drift.
Why it matters
Database drift occurs when the actual state of your database schema deviates from what’s defined in your version-controlled changelogs. This can happen due to manual “hotfixes” in production, environmental inconsistencies, or mistakes. Drift is a major cause of deployment failures and unexpected application behavior. Detecting and resolving drift is a critical skill for maintaining database stability and ensuring consistent deployments across environments.
Concepts explained simply
Imagine your changelog as the “blueprint” of your database. Drift is when the actual building (your database) no longer perfectly matches the blueprint.
- Database Drift: Any unmanaged change to the database schema (or even data that impacts schema functionality, like a missing lookup value) that is not recorded in the Liquibase changelog.
- Liquibase OSS Diffing (
diffanddiff-changelog):diff: Compares two database schemas (or a database to a changelog/snapshot) and lists the differences. It tells you what is different.diff-changelog: Takes the differences identified by adiffoperation and generates new changesets (in a changelog file) to apply those differences to the target database. It tells you how to fix the difference.- Limitation: OSS diffing is point-in-time. It requires you to define a “reference” (either another database or a Liquibase snapshot) to compare against. It doesn’t inherently store or track historical drift.
- Liquibase Pro Drift Capabilities:
- Continuous Drift Detection: Pro can monitor your database automatically and continuously check for drift. Instead of you explicitly running
diff, it can alert you when drift occurs. - Drift Reports: Generates detailed, often web-based reports highlighting drift, making it easier to visualize and manage.
- Targeted Drift Remediation: Tools to help you decide whether to “fix forward” (generate a new changeset to incorporate the drift into your changelog) or “revert” (undo the manual change in the database).
- Excluding Objects from Drift: Pro offers more robust ways to exclude objects (e.g., temporary tables, monitoring views) from drift detection that you don’t want managed by Liquibase.
- Continuous Drift Detection: Pro can monitor your database automatically and continuously check for drift. Instead of you explicitly running
- Noisy Diffs: Sometimes
diffreports irrelevant differences (e.g., system tables, comments, specific default values for columns that are generated differently by different DB versions). Learning to filter these is essential. - Remediation Strategies:
- Adopt (Fix-Forward): If the manual change was legitimate (e.g., emergency hotfix), capture it as a new changeset using
diff-changelogand merge it into your changelog. - Revert: If the manual change was incorrect or unauthorized, use
rollback(or manual SQL) to revert the database to its correct state, matching the changelog. - Exclude: If the difference is a known, desired difference (e.g., an object managed outside Liquibase, or a generated artifact), configure Liquibase to ignore it in future diffs.
- Adopt (Fix-Forward): If the manual change was legitimate (e.g., emergency hotfix), capture it as a new changeset using
UI navigation (if any) with “what is where” and step-by-step
Liquibase Hub (a Liquibase Pro feature) provides a web-based interface for continuous drift detection and reporting.
- Registering a Project with Liquibase Hub:
- First, you need a Liquibase Pro license key configured in
liquibase.propertiesor as an environment variable (LIQUIBASE_PRO_LICENSE_KEY). - Run
liquibase register-changelog --url=<hub_url>(or similar command, current Pro versions often auto-register whenupdateis run with a Hub API key configured). - This links your local changelog to a project in Liquibase Hub.
- First, you need a Liquibase Pro license key configured in
- Viewing Drift Reports in Liquibase Hub:
- Log in to Liquibase Hub (usually
hub.liquibase.com). - Navigate to your Projects list.
- Select the specific Project you registered.
- Look for the Drift or Observability section in the left navigation pane.
- Here, you’ll see a dashboard showing detected drift, historical trends, and details of each drifted object. You can typically drill down to see the exact SQL differences, when drift was detected, and by which environment.
- “What is where”:
- Projects: Top-level organization for your changelogs.
- Environments: Where you link specific database connections to your project (e.g., Dev, QA, Prod). Drift is monitored per environment.
- Drift/Observability Dashboard: Provides an overview of drift status, alerts, and detailed reports.
- Drifted Objects List: Lists tables, columns, indexes, etc., that deviate from the changelog.
- Schema Comparison: Shows side-by-side comparison of the changelog’s definition versus the current database’s actual definition.
- Log in to Liquibase Hub (usually
Step-by-step lab(s) with copy-pasteable commands and expected output
We’ll use our H2 sandbox.
Setup:
Start with a clean H2 database.
my-drift-project/liquibase.properties (H2 in-memory, changelog-file: master.xml).
my-drift-project/master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="baseline">
<comment>Create initial products table</comment>
<createTable tableName="products">
<column name="id" type="int"> <constraints primaryKey="true" nullable="false"/> </column>
<column name="name" type="varchar(255)"> <constraints nullable="false"/> </column>
</createTable>
</changeSet>
<changeSet id="2" author="baseline">
<comment>Add price column</comment>
<addColumn tableName="products">
<column name="price" type="numeric(10,2)" defaultValueNumeric="0.00"/>
</addColumn>
</changeSet>
</databaseChangeLog>
Lab: OSS Drift Detection and Remediation
Initial
updateto establish baseline:liquibase --defaults-file=liquibase.properties updateExpected Output: Changesets 1 and 2 executed.
Introduce manual drift (outside Liquibase): Open an H2 console or use
liquibase sqlto connect to the H2 database (jdbc:h2:mem:testdb) and make a manual change.# Run from your terminal, assuming H2 DB is still running in memory liquibase --defaults-file=liquibase.properties sql --sql="ALTER TABLE products ADD COLUMN description VARCHAR(500);"Expected Output: SQL executed successfully.
Detect drift with
diff: Compare the current database state (which now hasdescription) with the definition inmaster.xml.- Reference database for
diff: For this, we often use the same database as the reference to compare its current state against the changelog. Alternatively, you can use a fresh empty database, or a database representing a “golden” state. Let’s compare against a fresh empty H2 to highlight everything that’s in ourtestdb.
# Create a temporary empty H2 database for reference # And run diff against it. liquibase --defaults-file=liquibase.properties \ --reference-url="jdbc:h2:mem:empty_db;DB_CLOSE_DELAY=-1" \ --reference-username="sa" \ --reference-password="" \ diffExpected Output (truncated):
Reference Database: jdbc:h2:mem:empty_db;DB_CLOSE_DELAY=-1 Comparison Database: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 Differences: - Missing Table: PUBLIC.DATABASECHANGELOG - Missing Table: PUBLIC.DATABASECHANGELOGLOCK - Missing Table: PUBLIC.PRODUCTS - Missing Column: PUBLIC.PRODUCTS.ID - Missing Column: PUBLIC.PRODUCTS.NAME - Missing Column: PUBLIC.PRODUCTS.PRICE - Unexpected Column: PUBLIC.PRODUCTS.DESCRIPTION <-- This is our drift!Note: If you wanted to compare your current changelog against the database, you’d specify the changelog as the reference source (more complex, often easier to use
diff-changelogdirectly). For simple drift, comparing the database against an empty one highlights all unmanaged changes.- Reference database for
Remediate drift using
diff-changelog(Adopt strategy): Generate a changelog to “adopt” the manual change.# Generate a changelog of differences between our changelog and the current database # The default reference for diff-changelog is the current changelog-file. # The comparison database is the one specified in liquibase.properties. liquibase --defaults-file=liquibase.properties diff-changelog \ --output-file=drift-remediation.xml \ --include-objects="column(products.description)" # Focus on the drifted columnExpected Output:
drift-remediation.xmlis created.drift-remediation.xmlcontent:<?xml version="1.0" encoding="UTF-8" standalone="no"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"> <changeSet author="generated" id="1701388800000-1"> <!-- timestamp id --> <addColumn tableName="products"> <column name="description" type="VARCHAR(500)"/> </addColumn> </changeSet> </databaseChangeLog>Now, include this
drift-remediation.xmlinto yourmaster.xml:<!-- In master.xml, add after existing changesets --> <include file="drift-remediation.xml" relativeToChangelogFile="true"/>Run
updateagain:liquibase --defaults-file=liquibase.properties updateExpected Output: No new changesets run, as the
addColumnindrift-remediation.xmlis detected by Liquibase as already existing in the database (because of the manual drift). However, the new changeset is now recorded inDATABASECHANGELOG. This effectively “adopts” the drift.Handling Noisy Diffs (Excluding objects): Imagine your database has a system-generated index or view that you don’t want Liquibase to report as drift.
- Modify your
liquibase.propertiesto exclude these types of objects.# liquibase.properties # ... other properties diffExcludeObjects: ^DATABASECHANGELOG$, ^DATABASECHANGELOGLOCK$, ^MY_TEMPORARY_INDEX$ # Regex patterns - Rerun
diffordiff-changelog. The excluded objects should no longer appear in the report. This is especially useful fordiff-changelogto prevent it from generating changesets for objects you don’t manage.
- Modify your
Exercises (5+ per major section)
Assume you have a my-diff-project with liquibase.properties (H2 in-memory) and master.xml from the lab above. Start with a clean database for each exercise.
Detecting Basic Column Drift:
- Run
liquibase updateto apply changesets 1 and 2 (products table withid,name,price). - Manually add a column
weight(NUMERIC(5,2)) to theproductstable usingliquibase sql. - Run
liquibase diff. What does the output show regarding theweightcolumn? - Hint: Look for
Unexpected Column.
- Run
Drift Remediation - Revert Strategy:
- From the drifted state in Exercise 1 (products table has
weightcolumn): - Instead of adopting, decide to revert this manual change.
- Using
liquibase sql, manuallyALTER TABLE products DROP COLUMN weight;. - Run
liquibase diffagain. Does it still show drift forweight? Why or why not? - Hint: Reverting manually fixes the drift without touching changelogs.
- From the drifted state in Exercise 1 (products table has
Drift Remediation - Adopt Strategy with
diff-changelog:- Start again with the drifted state from Exercise 1 (products table has
weight, changelog doesn’t). - Generate a new changelog file (
db/weight_column_drift.xml) usingdiff-changelogto capture the addition of theweightcolumn. Use--include-objects="column(products.weight)". - Inspect
db/weight_column_drift.xml. - Include
db/weight_column_drift.xmlin yourmaster.xml. - Run
liquibase update. Verify that no changes are applied (because the column already exists), but the changeset is now recorded inDATABASECHANGELOG. - Run
liquibase diffagain (using an empty H2 as reference). Doesweightstill show as drift? - Hint: After
update, the changelog now “knows” about the column.
- Start again with the drifted state from Exercise 1 (products table has
Noisy Diffs - Excluding System Objects:
- Run
liquibase update. Your H2 database now hasproducts,DATABASECHANGELOG,DATABASECHANGELOGLOCK. - Run
liquibase diff(comparingtestdbagainst an emptyempty_db). You’ll seeMissing Table: PUBLIC.DATABASECHANGELOGandMissing Table: PUBLIC.DATABASECHANGELOGLOCK. - Modify your
liquibase.propertiesto exclude these Liquibase internal tables fromdiffreports usingdiffExcludeObjects. - Rerun
liquibase diff. Confirm that the Liquibase internal tables no longer appear in thediffreport. - Hint:
diffExcludeObjectstakes a comma-separated list of regex patterns.
- Run
Liquibase Pro: Conceptual Drift Report:
- Scenario: Your production database is continuously monitored by Liquibase Pro/Hub. A DBA made an emergency manual change to add a column
last_updated_byto theorderstable to track a critical hotfix. - Question: How would Liquibase Pro/Hub detect and report this drift? Describe the typical UI experience (as explained in this section).
- Question: What’s the key difference in how this drift is detected compared to manually running
liquibase diff? - Hint: Focus on the continuous and reporting aspects of Pro.
- Scenario: Your production database is continuously monitored by Liquibase Pro/Hub. A DBA made an emergency manual change to add a column
Solutions (Hints)
Detecting Basic Column Drift:
liquibase sql --sql="ALTER TABLE products ADD COLUMN weight NUMERIC(5,2);"liquibase diffoutput will includeUnexpected Column: PUBLIC.PRODUCTS.WEIGHT.
Drift Remediation - Revert Strategy:
liquibase sql --sql="ALTER TABLE products DROP COLUMN weight;"liquibase diffwill no longer reportUnexpected Column: PUBLIC.PRODUCTS.WEIGHTbecause the column is gone. The database now matches the changelog.
Drift Remediation - Adopt Strategy with
diff-changelog:liquibase diff-changelog --output-file=db/weight_column_drift.xml --include-objects="column(products.weight)"db/weight_column_drift.xmlwill contain anaddColumnchangeset.- After including and running
update,liquibase diff(against an empty reference) will not reportUnexpected Column: PUBLIC.PRODUCTS.WEIGHT. It will appear asMissing Columnin the empty DB (i.e., it’s part of our managed schema now).
Noisy Diffs - Excluding System Objects:
liquibase.properties:diffExcludeObjects: DATABASECHANGELOG, DATABASECHANGELOGLOCK- Running
liquibase diffwill now omit these tables from the report, focusing on your application schema.
Liquibase Pro: Conceptual Drift Report:
- Detection/Reporting: Liquibase Pro agents (or integrated pipelines) configured to monitor the production database would periodically (e.g., hourly) perform a schema comparison against the expected state (from the registered changelog in Hub). Upon detecting the
last_updated_bycolumn, it would:- Log an event indicating drift.
- Generate a Drift Report in Liquibase Hub for that environment/project.
- The Hub UI would display an alert or indicator for drift, showing the
last_updated_bycolumn as an “Unexpected Object”. - DBAs/Developers could drill into the report to see the exact DDL that created the column and compare it against the expected schema.
- Key Difference from
liquibase diff:- Continuous vs. On-Demand: Pro provides continuous, automated monitoring, rather than requiring manual execution of
diffeach time. - Centralized Reporting: Drift information is aggregated and visualized in Liquibase Hub, providing a single pane of glass for all environments, historical drift, and audit trails.
diffis a local CLI output. - Actionable Insights: Hub often provides options to “Acknowledge” drift, or generate a “Remediation Changelog” directly from the UI, making the adoption/reversion process more guided.
- Continuous vs. On-Demand: Pro provides continuous, automated monitoring, rather than requiring manual execution of
- Detection/Reporting: Liquibase Pro agents (or integrated pipelines) configured to monitor the production database would periodically (e.g., hourly) perform a schema comparison against the expected state (from the registered changelog in Hub). Upon detecting the
Pros and cons
Liquibase OSS Diffing (diff, diff-changelog)
- Pros: Free, readily available, powerful for ad-hoc comparisons and initial baselining, can generate remediation changelogs.
- Cons: Point-in-time analysis (not continuous), requires manual execution, output can be verbose and needs parsing, doesn’t provide historical drift tracking or centralized reporting.
Liquibase Pro Drift Detection
- Pros: Continuous, automated monitoring, centralized reporting and visualization in Liquibase Hub, historical drift tracking, advanced filtering/exclusion options, integration with workflows for guided remediation, proactive alerts.
- Cons: Requires a commercial Liquibase Pro license.
Common pitfalls and how to avoid them
- Ignoring Drift: Allowing drift to accumulate can lead to inconsistent environments, deployment failures, and debugging nightmares. Address drift promptly.
- Over-reliance on
diff-changelogfor Complex Refactorings: Whilediff-changelogis great for simpleaddColumnorcreateTableoperations, it might not generate the optimal or safest script for complex structural changes (e.g., renaming a column and migrating data). Manual review and enhancement are always required. - Noisy
diffReports: Not configuringdiffExcludeObjectscan clutter reports with irrelevant differences (system tables, temporary objects). Filter them out early. - Inconsistent Reference Databases: Always ensure your reference database (for
diffoperations) is truly representative of the “golden” state or the state you intend to compare against. - Not Testing Remediation: Never apply
diff-changeloggenerated output directly to production without thorough testing in lower environments. Review the SQL.
Troubleshooting and verification steps
diffnot showing expected differences:- Verify connectivity to both the comparison and reference databases.
- Ensure the schemas you are comparing are correct.
- Check for
diffExcludeObjectsthat might be unintentionally hiding differences. - Check for object name case sensitivity issues (Liquibase typically handles this, but underlying DB might be strict).
diff-changeloggenerating unwanted changesets:- Refine
diffExcludeObjectsto filter out irrelevant database objects. - Use
includeObjectswithdiff-changelogto focus only on specific objects you want to capture.
- Refine
- After applying
diff-changelogchangeset,diffstill shows drift:- This indicates the
diff-changelogdidn’t fully capture the difference or the generated changeset was flawed/skipped. - Manually inspect the
DATABASECHANGELOGtable to see if the new changeset was applied and recorded. - Manually inspect the database schema to confirm the change was truly applied.
- This indicates the
Takeaway checklist
- Understands database drift and its implications.
- Can use
liquibase diffto detect schema differences. - Can use
liquibase diff-changelogto generate new changelogs from differences (Adopt strategy). - Can filter noisy
diffreports usingdiffExcludeObjects. - Aware of the manual “Revert” strategy for drift.
- Understands the conceptual advantages of Liquibase Pro’s continuous drift detection and reporting.
9) Quality and Governance: Policy Checks (Pro), custom checks, secure practices
What you’ll learn
- The importance of quality gates and governance in database DevOps.
- Understanding Liquibase Pro’s Policy Checks framework.
- How to configure built-in policy checks.
- High-level overview of writing custom checks (e.g., in Python).
- Implementing secure practices for sensitive data, credentials, and access control.
Why it matters
Ensuring the quality, security, and compliance of database changes is paramount, especially in regulated industries or large organizations. Policy checks provide automated guardrails to prevent common errors, enforce architectural standards, and maintain security best practices before changes ever reach production. Integrating these checks into your CI/CD pipeline establishes strong governance and significantly reduces risks associated with database deployments.
Concepts explained simply
Policy Checks are like automated code reviews for your database changelogs. Instead of a human manually looking for specific patterns (e.g., “no ALTER TABLE without a default value”), Liquibase Pro can do it for you, automatically failing the build if a policy is violated.
- Quality Gates / Governance: A set of automated and manual checkpoints in your CI/CD pipeline that ensure changes meet predefined standards for quality, security, performance, and compliance before progressing to the next environment.
- Liquibase Pro Policy Checks: A framework within Liquibase Pro that allows you to define and enforce rules against your changelogs. These rules can check for:
- Common Anti-patterns:
alterTablewithoutdefault value,dropColumnwithoutprecondition,longTextcolumn withoutindex. - Naming Conventions: Table names starting with specific prefixes, column names following a pattern.
- Security Best Practices: No
GRANT ALL, no hardcoded sensitive data. - Performance Considerations: Missing indexes, inefficient data types.
- Common Anti-patterns:
- Built-in Checks: Liquibase Pro comes with a suite of pre-defined policy checks covering many common scenarios.
- Custom Checks: When built-in checks aren’t enough, you can extend the framework by writing your own checks in various languages (e.g., Python, Java). These allow you to enforce highly specific organizational or architectural rules.
- Secure Practices:
- Secrets Management: Never hardcode database credentials, API keys, or license keys in changelogs or properties files that are committed to source control. Use environment variables (e.g.,
LIQUIBASE_COMMAND_USERNAME), secret stores (Vault, AWS Secrets Manager, Azure Key Vault), or CI/CD secret management. - Least Privilege: Liquibase should run with the minimum necessary database privileges. Typically, it needs DDL and DML privileges on the application schema and read/write on
DATABASECHANGELOGandDATABASECHANGELOGLOCK. AvoidDBAorsysadminroles. - Access Control: Restrict who can run Liquibase commands and where. In CI/CD, ensure service accounts have appropriate permissions.
- Auditability: Ensure all changes (and who approved them) are traceable. Liquibase’s
DATABASECHANGELOGprovides an audit trail of applied changes.
- Secrets Management: Never hardcode database credentials, API keys, or license keys in changelogs or properties files that are committed to source control. Use environment variables (e.g.,
UI navigation (if any) with “what is where” and step-by-step
Liquibase Hub (Pro feature) plays a role in configuring and reporting policy checks.
Configuring Policy Checks (Liquibase Hub):
- Log in to Liquibase Hub.
- Navigate to your Project.
- Look for the “Policy” or “Governance” section in the navigation.
- Here, you can typically:
- Enable/Disable Built-in Checks: Toggle predefined rules like “No
DROP TABLEwithout preconditions” or “Require comments on changesets.” - Configure Severity: Set
WARNING,FAIL, orINFOfor each policy violation. This determines if a violation will halt the pipeline (FAIL) or just log a warning. - Define Custom Checks: Upload or configure the path to your custom check scripts (e.g., Python files).
- Apply to Environments: Choose which policies apply to which environments (e.g., stricter policies for
prod).
- Enable/Disable Built-in Checks: Toggle predefined rules like “No
- “What is where”: The Policy section is where you centralize your rules. These rules are then applied when Liquibase executes with Pro configured to connect to Hub.
Viewing Policy Violations (Liquibase Hub):
- When you run
liquibase update(orvalidate) with Policy Checks enabled and integrated with Hub, the results are sent to Hub. - In the Project dashboard, or under a specific Environment’s details, you’ll see a “Policy Violations” or “Build Status” section.
- This shows:
- Number of violations.
- Severity of violations (FAIL, WARNING).
- Which changesets violated which policies.
- Details of the violation (e.g., “Changeset
1::author::pathis missing a comment”).
- When you run
Step-by-step lab(s) with copy-pasteable commands and expected output
Note: To fully execute labs for Policy Checks, a Liquibase Pro license is required, and configuration with Liquibase Hub is beneficial. The examples here will demonstrate the configuration locally and conceptual execution.
Setup:
my-governance-project/liquibase.properties (H2 in-memory).
my-governance-project/master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<!-- Changeset without comment (will violate a common policy) -->
<changeSet id="1" author="admin">
<createTable tableName="configurations">
<column name="key" type="varchar(100)"> <constraints primaryKey="true"/> </column>
<column name="value" type="text"/>
</createTable>
</changeSet>
<!-- Changeset with a potential security risk (bad permission grant) -->
<changeSet id="2" author="admin">
<comment>Grant wide permissions (bad practice)</comment>
<sql>GRANT ALL ON ALL TABLES IN SCHEMA PUBLIC TO PUBLIC;</sql>
</changeSet>
<!-- Changeset for a feature, with comment -->
<changeSet id="3" author="feature_dev">
<comment>Adding feature table</comment>
<createTable tableName="features">
<column name="id" type="int"> <constraints primaryKey="true"/> </column>
<column name="name" type="varchar(255)"/>
</createTable>
</changeSet>
</databaseChangeLog>
Create a policy.json file to define local policy checks (this is a simplified representation for demonstration; typically configured via Hub or a richer YAML/JSON config file).
my-governance-project/policy.json:
{
"policies": [
{
"name": "changeset-must-have-comment",
"severity": "FAIL",
"description": "All changesets must include a <comment> tag.",
"check": {
"type": "javascript",
"script": "return !changeSet.comment || changeSet.comment.trim() === '';"
}
},
{
"name": "no-grant-all-to-public",
"severity": "WARNING",
"description": "Avoid using 'GRANT ALL ... TO PUBLIC' statements.",
"check": {
"type": "javascript",
"script": "return changeSet.changes.some(change => change.sql && /GRANT\\s+ALL\\s+ON\\s+ALL\\s+TABLES\\s+IN\\s+SCHEMA\\s+PUBLIC\\s+TO\\s+PUBLIC/i.test(change.sql));"
}
}
]
}
Note: The policy.json format above is illustrative. Liquibase Pro typically uses a more robust configuration mechanism (often tied to Hub, or a .yaml configuration file for advanced local policy checks) for its built-in and custom checks. The JavaScript snippets here are a simplification of custom check logic.
Lab: Policy Checks in Action (Conceptual for OSS, Actual for Pro)
Configure Liquibase Pro License Key (Conceptual): For Liquibase Pro, you’d add your license key.
# liquibase.properties # ... liquibaseProLicenseKey: YOUR_LIQUIBASE_PRO_LICENSE_KEY_HERERun
validatewith Policy Checks: Liquibase Pro’svalidatecommand is where policy checks are typically run. For OSS, you won’t have the rich policy checking framework, but you can conceptually understand the process.# For Liquibase Pro (assuming policy config is integrated via Hub or local config) liquibase --defaults-file=liquibase.properties validateExpected Output (Conceptual for Pro):
Liquibase 4.33.0 by Liquibase Successfully acquired change log lock Running Liquibase against testdb@jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 --- Liquibase Policy Check Results --- Violation: changeset-must-have-comment Changeset: master.xml::1::admin Severity: FAIL Description: All changesets must include a <comment> tag. Violation: no-grant-all-to-public Changeset: master.xml::2::admin Severity: WARNING Description: Avoid using 'GRANT ALL ... TO PUBLIC' statements. Policy Checks found 2 violations. 1 critical (FAIL). Liquibase command failed with exception: Policy check failed: 1 critical violation(s) found.The
validatecommand withFAILseverity policies would halt the execution.Remediate Policy Violations:
- Add a comment to ChangeSet 1:
<!-- master.xml --> <changeSet id="1" author="admin"> <comment>Initial setup for configurations table</comment> <!-- ADD THIS --> <createTable tableName="configurations"> ... </createTable> </changeSet> - Change ChangeSet 2 to use specific grants (or remove it for this example):
<!-- master.xml --> <changeSet id="2" author="admin"> <comment>Grant SELECT on configurations to reporting_role</comment> <sql>GRANT SELECT ON PUBLIC.configurations TO reporting_role;</sql> </changeSet> - Rerun
liquibase --defaults-file=liquibase.properties validate. Now it should pass (or only show theWARNINGif the grant policy is still active).
- Add a comment to ChangeSet 1:
Lab: Secure Practices (Conceptual)
Environment Variables for Credentials: (Reiterating from Section 1.3/2.3, but emphasizing its security aspect). Instead of
username: sa,password:inliquibase.properties:# liquibase.properties url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 driver: org.h2.Driver changelog-file: master.xml # username and password are now sourced from environment variablesAnd then run:
export LIQUIBASE_COMMAND_USERNAME="sa" export LIQUIBASE_COMMAND_PASSWORD="" liquibase --defaults-file=liquibase.properties updateThis prevents credentials from being committed to source control.
Principle of Least Privilege:
- Conceptual Exercise: If Liquibase only needs to create tables, add columns, and insert data into a specific schema (
app_schema), its database user account (liquibase_user) should only haveCREATE,ALTER,INSERT,UPDATE,DELETEprivileges onapp_schema, andSELECT,INSERT,UPDATE,DELETEon theDATABASECHANGELOGandDATABASECHANGELOGLOCKtables. It should not haveDROP DATABASEorCREATE ROLEprivileges unless explicitly needed for a specific change. - Verification: A DBA (or automated script) should regularly audit database user permissions to ensure
liquibase_useradheres to least privilege.
- Conceptual Exercise: If Liquibase only needs to create tables, add columns, and insert data into a specific schema (
Exercises (5+ per major section)
Policy Check - Missing Comment (Conceptual for Pro):
- Create a
master.xmlwith three simple changesets:- Changeset 1:
createTable(no comment). - Changeset 2:
addColumn(with comment). - Changeset 3:
createIndex(no comment).
- Changeset 1:
- Assuming you have Liquibase Pro and a policy like
changeset-must-have-comment(from the lab’spolicy.json) configured toFAIL. - Run
liquibase validate. Which changesets fail the policy? What’s the expected output? - Hint: The
FAILseverity will halt execution.
- Create a
Policy Check - Naming Convention (Conceptual for Pro):
- Add a new changeset to your
master.xml:id="4", author="dev":createTablewithtableName="bad_table_name"(violating a convention that table names should start withapp_).
- Conceptual Check: If you had a custom Pro policy that
table names must start with 'app_'configured withseverity: WARNING. - Run
liquibase validate. What would be the expected outcome? Would the build fail? - Hint:
WARNINGseverity typically allows thevalidatecommand to pass but reports the violation.
- Add a new changeset to your
Custom Policy Check Idea (Python Outline):
- Problem: You want to enforce that all
addColumnchanges forVARCHARtypes must specify a maximum length (e.g.,VARCHAR(255)not justVARCHAR). - Task: Outline how you would structure a custom Python policy check for this.
- Hint: Consider how the Liquibase Pro custom check framework receives information about the changeset (e.g., as a parsed JSON/XML object) and how you would iterate through changes to find
addColumnoperations and check column types/lengths. You’d return a violation if the rule is broken.
- Problem: You want to enforce that all
Secure Credential Handling:
- Take your existing
liquibase.propertiesfile for the H2 database. - Remove the
usernameandpasswordlines from the file. - Create a shell script (e.g.,
run_liquibase.sh) that setsLIQUIBASE_COMMAND_USERNAMEandLIQUIBASE_COMMAND_PASSWORDenvironment variables and then callsliquibase update. - Execute the script. Does Liquibase still connect and update?
- Hint: This is a direct application of the secure practices section.
- Take your existing
Auditability Verification:
- Perform a
liquibase updateon your H2 database with a few changesets. - Connect to the H2 database using a SQL client tool (or
liquibase sql). - Query the
DATABASECHANGELOGtable. What information does it provide about the applied changes? How would this contribute to an audit trail? - Hint: Look at all columns in
DATABASECHANGELOG.
- Perform a
Solutions (Hints)
Policy Check - Missing Comment (Conceptual for Pro):
liquibase validatewouldFAILand report violations for Changeset 1 and Changeset 3.
Policy Check - Naming Convention (Conceptual for Pro):
liquibase validatewould pass (exit code 0) but would output aWARNINGfor Changeset 4 regarding thebad_table_name. The policy’sWARNINGseverity allows continuation.
Custom Policy Check Idea (Python Outline):
# Example outline for a Python custom check # This would be invoked by Liquibase Pro's Policy framework # The 'changeset' object would be provided by Liquibase. # It would be a parsed representation of the current changeset. def check_varchar_length(changeset_json): violations = [] changeset = json.loads(changeset_json) # Liquibase passes JSON string for change in changeset.get('changes', []): if 'addColumn' in change: column_config = change['addColumn'].get('columns', [])[0]['column'] # Simplified if column_config.get('type', '').lower().startswith('varchar') and '(' not in column_config.get('type'): violations.append({ "changesetId": changeset['id'], "author": changeset['author'], "logicalFilePath": changeset['logicalFilePath'], "policyName": "varchar-must-have-length", "severity": "FAIL", "message": f"VARCHAR column '{column_config.get('name')}' in changeset {changeset['id']} must specify a length." }) # Add checks for createTable with varchar columns, etc. return violations # Return empty list if no violationsSecure Credential Handling:
run_liquibase.sh:#!/bin/bash export LIQUIBASE_COMMAND_USERNAME="sa" export LIQUIBASE_COMMAND_PASSWORD="" liquibase --defaults-file=liquibase.properties update- Executing this script will succeed, demonstrating that credentials can be passed securely via environment variables.
Auditability Verification:
- Query
SELECT * FROM DATABASECHANGELOG; - It provides
ID,AUTHOR,FILENAME(logicalFilePath),DATEEXECUTED,ORDEREXECUTED,EXECTYPE,MD5SUM,DESCRIPTION,COMMENTS,TAG,LIQUIBASE,CONTEXTS,LABELS,DEPLOYMENT_ID. - This table offers a comprehensive audit trail:
- What was changed?:
ID,FILENAME(logicalFilePath),DESCRIPTION(from comment). - Who changed it?:
AUTHOR. - When?:
DATEEXECUTED. - In what order?:
ORDEREXECUTED. - How was it changed?:
MD5SUM(ensures content integrity),EXECTYPE(EXECUTED, ROLLED_BACK, MARK_RAN). - Under what conditions?:
CONTEXTS,LABELS. - Which Liquibase version?:
LIQUIBASE. - Which deployment run?:
DEPLOYMENT_ID.
- What was changed?:
- Query
Pros and cons
Policy Checks (Pro)
- Pros: Automated enforcement of standards, prevents errors early in the pipeline, improves code quality and security, ensures compliance, provides structured feedback, configurable severity levels.
- Cons: Requires Liquibase Pro license, initial setup and custom check development can require effort.
Custom Checks
- Pros: Tailored enforcement of highly specific organizational rules, extends Liquibase’s capabilities, integrates into the existing framework.
- Cons: Requires programming skills (Python/Java), maintenance overhead for custom code.
Secure Practices (Secrets Management)
- Pros: Prevents credential exposure in source control, reduces attack surface, adheres to industry best practices, enhances overall security posture.
- Cons: Requires integration with dedicated secret management systems or careful environment variable handling, adds a layer of configuration complexity.
Least Privilege Principle
- Pros: Minimizes the impact of a compromised credential, reduces potential for accidental destructive operations, improves overall database security.
- Cons: Requires careful planning of database roles and permissions, can be challenging to implement in complex multi-application environments.
Common pitfalls and how to avoid them
- Weak Policy Enforcement: Setting all policies to
WARNINGin production bypasses the “gate” effect. UseFAILfor critical violations. - Overly Strict Policies: Policies that are too strict or not well-communicated can hinder developer productivity. Balance strictness with practicality.
- Ignoring Policy Failures: Failing a build due to a policy violation is a feature, not a bug. Investigate and fix the underlying issue.
- Hardcoding Credentials in Scripts: Even if not in
liquibase.properties, putting passwords directly in shell scripts that get committed is a risk. Use environment variables (viaexport) orvaultintegration. - Broad Database Permissions: Granting
rootorDBAaccess to the Liquibase user in any environment is a major security flaw. Review and restrict privileges.
Troubleshooting and verification steps
- Policy check not running/reporting:
- Ensure Liquibase Pro license is correctly configured.
- Verify integration with Liquibase Hub (if applicable).
- Check
liquibase.propertiesfor policy configuration directives (e.g.,policyConfigFile,policyValidationLevel). - Increase
logLeveltoDEBUGto see policy framework activity.
- Custom check not working:
- Check custom script logs for errors.
- Ensure the script path is correct and it’s executable/callable by Liquibase.
- Validate the JSON/XML structure being passed to your custom script.
- Credential issues:
- Double-check environment variable names (
LIQUIBASE_COMMAND_USERNAME, etc.). - Verify the shell script exports variables correctly.
- Test database connectivity manually with the same credentials.
- Double-check environment variable names (
Takeaway checklist
- Understands the value of quality gates and governance for database changes.
- Aware of Liquibase Pro’s Policy Checks for automated validation.
- Can conceptually configure built-in and custom policy checks.
- Can implement secure practices like using environment variables for credentials.
- Understands the principle of least privilege for database users.
- Recognizes the
DATABASECHANGELOGtable as a critical audit trail.
10) Observability and Reports: Update reports, structured logging, integrating with Splunk/Datadog/CloudWatch (Pro)
What you’ll learn
- The importance of observability for database migration processes.
- Generating and utilizing Liquibase update reports.
- Configuring and consuming structured logging from Liquibase.
- High-level concepts of integrating Liquibase logs with centralized monitoring tools (Splunk, Datadog, CloudWatch) (Pro feature).
Why it matters
Observability provides visibility into the “health” and execution of your database migrations. Knowing precisely what happened during an update operation, any warnings or errors, and the resulting state of the database is crucial for debugging, auditing, compliance, and proactive problem-solving. Structured logging and integration with monitoring tools are essential for enterprise-grade database DevOps, allowing teams to quickly detect issues and understand the impact of changes.
Concepts explained simply
Observability is about being able to answer questions about what happened during your Liquibase runs without having to directly connect to the database or pore over raw text logs.
- Observability: In the context of database migrations, it’s the ability to monitor, analyze, and gain insights into the migration process. This includes logs, metrics, and traces (though traces are less common for Liquibase directly).
- Update Reports: Liquibase can generate an “update report” artifact, often in HTML or JSON format. This report summarizes the
updateoperation, including:- Which changesets were executed.
- Any warnings or errors encountered.
- Start/end times and duration.
- Deployment ID.
- Environmental details.
- OSS: Basic update report generation is available.
- Pro: Enhanced reports, often integrated with Liquibase Hub, providing more detail and context, and can be centralized.
- Structured Logging: Instead of plain text logs, Liquibase (especially Pro) can emit logs in structured formats like JSON.
- Benefits: Easier for machines to parse, query, and analyze (e.g., filter all errors, group by changeset ID, visualize trends).
- Consumption: These logs can be ingested by centralized logging systems.
- Integration with Monitoring Tools (Pro Feature):
- Splunk, Datadog, AWS CloudWatch, Azure Monitor, Google Cloud Logging: These are centralized logging and monitoring platforms.
- How it works (High-Level): Liquibase Pro can be configured to send its structured logs (or be consumed by agents) to these platforms.
- Log Forwarders: Agents (e.g., Splunk Universal Forwarder, Datadog Agent) collect logs from file systems.
- Direct API/SDK: For cloud-native logging, Liquibase could potentially use SDKs or stream to endpoints.
- Liquibase Hub: Hub acts as a central collector for Liquibase Pro events and can then forward these to other monitoring systems.
- Benefits: Real-time alerting on deployment failures, dashboards for migration trends, easier root cause analysis.
UI navigation (if any) with “what is where” and step-by-step
Liquibase Hub (Pro feature) serves as a central point for observability.
Viewing Update Reports/Deployment History (Liquibase Hub):
- Log in to Liquibase Hub.
- Navigate to your Project.
- Go to the “Deployments” or “History” section.
- Here, you’ll see a list of all
updateoperations performed on your registered environments. - Click on a specific Deployment ID to view its detailed Update Report. This report will include:
- Summary of changes applied.
- Changesets executed/skipped.
- Any warnings/errors.
- Policy check violations.
- Drift status (if monitored for that environment).
- Performance metrics for the deployment.
- “What is where”: The Deployments/History section provides a chronological audit of all Liquibase activity, with drill-down to individual update reports.
Configuring Log Forwarding (Conceptual in Hub):
- Within Liquibase Hub (often under “Settings” or “Integrations” for your project), there might be configurations to:
- Enable logging to external systems: Toggle options to send data to Splunk, Datadog, etc.
- API Keys/Endpoints: Provide necessary credentials or URLs for the external monitoring system.
- “What is where”: Hub acts as an integration layer, simplifying the process of sending Liquibase data to your existing enterprise observability tools.
- Within Liquibase Hub (often under “Settings” or “Integrations” for your project), there might be configurations to:
Step-by-step lab(s) with copy-pasteable commands and expected output
We’ll use our my-observability-project with H2.
Setup:
my-observability-project/liquibase.properties (H2 in-memory, changelog-file: master.xml).
my-observability-project/master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="user_management">
<comment>Create users table</comment>
<createTable tableName="users">
<column name="id" type="int"> <constraints primaryKey="true" nullable="false"/> </column>
<column name="username" type="varchar(100)"> <constraints nullable="false" unique="true"/> </column>
</createTable>
</changeSet>
<changeSet id="2" author="user_management">
<comment>Add email column to users</comment>
<addColumn tableName="users">
<column name="email" type="varchar(255)"/>
</addColumn>
</changeSet>
<changeSet id="3" author="data_seed" contexts="dev">
<comment>Insert test user for dev</comment>
<insert tableName="users">
<column name="id" valueNumeric="100"/>
<column name="username" value="testuser"/>
<column name="email" value="test@example.com"/>
</insert>
</changeSet>
</databaseChangeLog>
Lab: Update Reports and Structured Logging
Generate a Basic Update Report (OSS/Pro):
liquibase --defaults-file=liquibase.properties update \ --report-path=./reports/update-report.htmlExpected Output: An
update-report.htmlfile will be generated in./reports/. Open it in a browser. It should summarize theupdaterun, list applied changesets (1 and 2), and show3as skipped (due to nodevcontext specified yet).Generate a Report with Context (OSS/Pro): Clear your H2 (restart process).
liquibase --defaults-file=liquibase.properties update --contexts=dev \ --report-path=./reports/update-report-dev.jsonExpected Output:
update-report-dev.jsonwill be generated. Open it in a text editor. You’ll see a JSON structure with details, including changesets 1, 2, and 3 executed.Enable Structured Logging (Pro Feature - Conceptual): This typically involves adding specific properties to
liquibase.propertiesfor Liquibase Pro, or configuring it via Hub.# liquibase.properties (conceptual for Pro) # ... logLevel: INFO # Enable structured JSON logging logging.json: true logging.json.file: ./logs/liquibase-$(date +%Y%m%d%H%M%S).json logging.json.includeMdc: true # Include MDC context if availableWith these properties, subsequent
liquibasecommands (e.g.,update) would output JSON formatted logs to the specified file.Conceptual
logs/liquibase-<timestamp>.jsonoutput example:{"timestamp":"2025-10-01T16:35:00.123Z","level":"INFO","thread":"main","message":"Successfully acquired change log lock","deploymentId":"123456789","command":"update","changelogFile":"master.xml"} {"timestamp":"2025-10-01T16:35:00.456Z","level":"INFO","thread":"main","message":"Starting executing changeSet master.xml::1::user_management","deploymentId":"123456789","changesetId":"1","author":"user_management","changelogFile":"master.xml"} {"timestamp":"2025-10-01T16:35:01.000Z","level":"INFO","thread":"main","message":"ChangeSet master.xml::1::user_management ran successfully","deploymentId":"123456789","changesetId":"1","author":"user_management","changelogFile":"master.xml","executionTimeMs":544} ...This JSON output is easily parseable by log aggregators.
Lab: Integrating with Centralized Monitoring (Pro Feature - Conceptual)
- High-Level Integration with Splunk/Datadog/CloudWatch Logs:
- Scenario: Your CI/CD pipeline runs
liquibase updatecommands. You want these logs to appear in Splunk. - Method 1 (File-based Forwarding):
- Configure Liquibase Pro to write structured JSON logs to a specific file path (e.g.,
/var/log/liquibase/migration.json). - Install a Splunk Universal Forwarder on the CI/CD agent or server.
- Configure the Splunk Forwarder to monitor
/var/log/liquibase/migration.jsonand send new lines to your Splunk indexer. - In Splunk, create a
sourcetypefor Liquibase JSON logs and set up dashboards/alerts to monitor for error messages, deployment IDs, execution times, etc.
- Configure Liquibase Pro to write structured JSON logs to a specific file path (e.g.,
- Method 2 (Liquibase Hub as intermediary):
- Ensure Liquibase Pro is configured to send its events to Liquibase Hub.
- In Liquibase Hub’s settings, configure an integration to forward events to Splunk (or Datadog/CloudWatch Logs). Hub handles the API calls and authentication.
- Benefits: Developers and DBAs can use Splunk dashboards to see all migration activity, troubleshoot issues quickly, set alerts for
FAILseverity policy violations, or performance regressions.
- Scenario: Your CI/CD pipeline runs
Exercises (5+ per major section)
Analyze an Update Report (OSS/Pro):
- Run
liquibase updateagainst yourmy-observability-project’smaster.xml(without contexts). - Generate an HTML report:
liquibase --defaults-file=liquibase.properties update --report-path=./reports/latest_update.html. - Open
latest_update.html. - Question: What is the “Deployment ID”? Where can you see which changesets ran? What about ChangeSet 3?
- Question: If there were any errors, how would the report highlight them?
- Run
JSON Report for CI/CD Consumption (OSS/Pro):
- Run
liquibase updatewith thedevcontext:liquibase --defaults-file=liquibase.properties update --contexts=dev. - Generate a JSON report:
liquibase --defaults-file=liquibase.properties update --contexts=dev --report-path=./reports/latest_update_dev.json. - Open
latest_update_dev.jsonin a code editor. - Question: How would a CI/CD pipeline easily extract information like
deploymentId,deploymentOutcome, or a list ofexecutedChangesetsfrom this JSON structure? Provide a conceptual Python or JQ snippet.
- Run
Conceptual Structured Logging for Errors (Pro):
- Scenario: You introduce a syntax error into ChangeSet 1 of your
master.xml(e.g., changecreateTabletocreatetableX). - Task: Describe what a Liquibase Pro JSON log output for
updatewould look like for this specific error. - Hint: Focus on the
level(e.g.,ERROR),message, and any other relevant fields likechangesetIdthat would be present in the JSON.
- Scenario: You introduce a syntax error into ChangeSet 1 of your
Integrating with CloudWatch Logs (Pro - Conceptual):
- Problem: You are deploying a serverless application using AWS Fargate, and Liquibase migrations run as part of the Fargate task. You want to send Liquibase logs to AWS CloudWatch Logs.
- Task: Describe two possible high-level approaches to get Liquibase’s output into CloudWatch Logs from a Fargate task.
- Hint: Consider Docker logging drivers, and if Liquibase Pro had direct integration.
Monitoring
DATABASECHANGELOGfor Observability:- After several
liquibase updateandrollbackoperations on your H2 database: - Use
liquibase sqlto query theDATABASECHANGELOGtable. - Question: Which columns in this table provide real-time observability into the history of deployments? How could this table itself be used as an audit source, or to build custom reports, even without Liquibase Pro/Hub?
- Hint: Think about
DATEEXECUTED,EXECTYPE,DEPLOYMENT_ID.
- After several
Solutions (Hints)
Analyze an Update Report (OSS/Pro):
- Deployment ID: A unique UUID/timestamp shown at the top.
- Executed Changesets: A list of changesets that were successfully applied, usually with their ID, author, and description.
- ChangeSet 3: Should be listed as “Skipped” because the
--contexts=devwas not supplied. - Errors: Errors would be prominently displayed with stack traces, potentially colored red in HTML.
JSON Report for CI/CD Consumption (OSS/Pro):
- The JSON report contains an array like
executedChangesetsorchangeSets. - JQ snippet:
cat ./reports/latest_update_dev.json | jq '.deploymentId' cat ./reports/latest_update_dev.json | jq '.deploymentOutcome' cat ./reports/latest_update_dev.json | jq '.executedChangesets | .[] | {id, author, description, executionTimeMs}' - Python snippet:
import json with open('./reports/latest_update_dev.json', 'r') as f: report = json.load(f) print(f"Deployment ID: {report['deploymentId']}") print(f"Outcome: {report['deploymentOutcome']}") for cs in report.get('executedChangesets', []): print(f" - {cs['id']} by {cs['author']}: {cs.get('description', '')} ({cs.get('executionTimeMs', 0)}ms)")
- The JSON report contains an array like
Conceptual Structured Logging for Errors (Pro):
{"timestamp":"...","level":"ERROR","thread":"main","message":"Unexpected error running Liquibase: Validation Error: Unknown change type 'createtableX'","deploymentId":"...","command":"update","errorDetails":"liquibase.exception.ChangeLogParseException: ...","changesetId":"1","author":"user_management","changelogFile":"master.xml"}Key fields:
level:"ERROR", detailedmessage,errorDetails(often stack trace), potentiallychangesetIdif the error occurs during a specific changeset’s processing.Integrating with CloudWatch Logs (Pro - Conceptual):
- Docker Logging Driver: Configure the Fargate task’s Docker container to use the
awslogslogging driver. This automatically forwards all containerstdout/stderrto a specified CloudWatch Log Group. Liquibase should print its output tostdout/stderr. - Liquibase Hub Integration: Run Liquibase Pro in the Fargate task, configured to send events to Liquibase Hub. Then, configure Liquibase Hub to forward events to AWS CloudWatch Logs (or an AWS Lambda function that ingests into CloudWatch).
- Docker Logging Driver: Configure the Fargate task’s Docker container to use the
Monitoring
DATABASECHANGELOGfor Observability:- Columns like
DATEEXECUTED,EXECTYPE(EXECUTED, ROLLED_BACK, MARK_RAN, FAILED),DEPLOYMENT_ID,LIQUIBASE,CONTEXTS, andLABELSare crucial. - This table is an audit source. It shows who (author), what (id, filename, description), when (dateexecuted), and how (exectype, checksum) every changeset was managed.
- Custom reports can be built by querying this table directly to track:
- Deployment frequency (
COUNT(DISTINCT DEPLOYMENT_ID)) - Changeset success rates (
COUNT(*) WHERE EXECTYPE = 'EXECUTED') - Rollback events (
WHERE EXECTYPE = 'ROLLED_BACK') - Changes by context/label.
- Deployment frequency (
- Columns like
Pros and cons
Update Reports
- Pros: Provides a clear, human-readable summary of a deployment, useful for audits and post-deployment review, easily shareable.
- Cons: Point-in-time snapshot, not continuously updated, HTML format isn’t machine-readable, JSON is better for automation.
Structured Logging (Pro)
- Pros: Machine-readable (JSON), enables powerful querying and analysis in log aggregators, simplifies debugging, facilitates automated alerting, provides rich context (changeset ID, deployment ID, timing).
- Cons: Requires Liquibase Pro license, initial setup of logging configuration/forwarders.
Integration with Centralized Monitoring Tools (Pro)
- Pros: Unified visibility across all services, real-time alerts, advanced analytics and dashboards, long-term historical data storage, robust audit trails, reduced mean time to recovery (MTTR) for database-related issues.
- Cons: Requires Liquibase Pro, setup and maintenance of monitoring infrastructure (Splunk, Datadog etc.), potential costs associated with these platforms.
Common pitfalls and how to avoid them
- Ignoring Logs: Just generating logs isn’t enough; they need to be actively monitored and analyzed.
- Unstructured Logs: Trying to parse plain text logs in an automated fashion is brittle. Embrace structured logging when possible.
- Too Much/Too Little Logging: Find the right balance. Too verbose and logs are unreadable; too sparse and you miss critical details.
INFOis often good for standard operations,DEBUGfor troubleshooting. - Log Retention Policy: Ensure your logging system retains Liquibase logs for an appropriate period for auditing and historical analysis.
- Alert Fatigue: Configure alerts judiciously. Only alert on critical errors (
FAILpolicy violations, exceptions), not every warning or successful deployment.
Troubleshooting and verification steps
- Report file not generated:
- Check
report-pathparameter spelling and permissions for the output directory. - Ensure Liquibase command completed (didn’t error out before report generation).
- Check
- Structured logs not appearing:
- Verify
logging.json: trueandlogging.json.fileproperties (for Pro). - Check file permissions for the log directory.
- Confirm your log forwarder/agent is running and correctly configured to pick up the Liquibase log file.
- Check the internal logs of your monitoring system for ingestion errors.
- Verify
- Data in
DATABASECHANGELOGis inconsistent:- Run
liquibase validateto check for checksum issues. - Do not manually modify
DATABASECHANGELOGdirectly unless absolutely necessary and with extreme caution. - Ensure all Liquibase commands are run with the same set of changelogs and configuration.
- Run
Takeaway checklist
- Understands why observability is vital for database changes.
- Can generate HTML and JSON update reports.
- Understands the benefits of structured logging.
- Has a high-level understanding of integrating Liquibase logs with centralized monitoring tools.
- Knows how
DATABASECHANGELOGserves as an internal audit/observability tool.
11) Docker Workflows: Official image usage, volumes, env vars, drivers, LPM, custom images
What you’ll learn
- Deep dive into using the official Liquibase Docker image.
- Mastering volume mounts for changelogs, properties, and drivers.
- Leveraging environment variables for configuration and sensitive data.
- Handling JDBC drivers in Docker: bundled, LPM installation, and custom Dockerfiles.
- Networking considerations for Dockerized Liquibase (e.g.,
host.docker.internal). - Building optimized custom Docker images for specific needs.
Why it matters
Docker provides a consistent, isolated, and repeatable environment for running Liquibase, which is crucial for modern CI/CD pipelines and team development. By standardizing on Docker, you eliminate “works on my machine” issues related to Java versions, Liquibase versions, and JDBC driver dependencies. Understanding advanced Docker patterns ensures your migrations are robust, secure, and efficient.
Concepts explained simply
Docker wraps Liquibase in a container, making it portable. You need to tell this container where your changelog files are, how to connect to the database, and any extra tools it might need (like JDBC drivers).
- Official Liquibase Docker Image:
liquibase/liquibaseon Docker Hub. Available in different tags (e.g.,4.33.0,4.33.0-alpinefor smaller size). - Volume Mounts (
-v): This is how you make files from your host machine (or other Docker volumes) available inside the container.$(pwd)/my-project:/liquibase/changelog: Mounts your local project directory into the container at/liquibase/changelog. Liquibase expects to find changelogs andliquibase.propertieshere by default.
- Environment Variables (
-e): A secure way to pass configuration values and sensitive data (like database credentials) to the container. Liquibase recognizes variables prefixed withLIQUIBASE_COMMAND_(e.g.,LIQUIBASE_COMMAND_URL). - JDBC Drivers in Docker:
- Bundled: Some drivers (like H2) are bundled.
- LPM Install (
liquibase lpm install): You can run LPM inside the container to install drivers on-the-fly. This adds overhead and isn’t persistent. - Custom Dockerfile: The recommended production approach. You create your own
DockerfilethatFROM liquibase/liquibaseand then adds the necessary JDBC drivers. This creates a pre-configured image.
- Networking:
localhostinside a Docker container refers to the container itself.host.docker.internal: A special DNS name provided by Docker Desktop (macOS/Windows) to access the host machine’slocalhost.- For Linux hosts, you might need to use the host’s IP, or connect via a shared Docker network if your database is in another container.
- Defaults File (
--defaults-file): Specifies the path toliquibase.propertiesinside the container.
Step-by-step lab(s) with copy-pasteable commands and expected output
Setup:
my-docker-project/liquibase.properties:
# Initial properties, often minimal for Docker if using ENV VARS
changelog-file: master.xml
# url, username, password, driver can be omitted if passed as env vars
my-docker-project/master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="docker_user">
<comment>Create docker_test_table</comment>
<createTable tableName="docker_test_table">
<column name="id" type="int"> <constraints primaryKey="true" nullable="false"/> </column>
<column name="data" type="varchar(255)"/>
</createTable>
</changeSet>
</databaseChangeLog>
Assume you have Docker installed and running.
Lab: Docker Workflows
Run Basic
updatewith H2 (Bundled Driver):cd my-docker-project docker run --rm -v $(pwd):/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1" \ -e LIQUIBASE_COMMAND_USERNAME="sa" \ -e LIQUIBASE_COMMAND_PASSWORD="" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \ liquibase/liquibase:4.33.0 \ updateExpected Output: The
docker_test_tableshould be created in the in-memory H2 database. This confirms basic mounting and environment variable usage.Add PostgreSQL Driver via LPM (Ephemeral):
- First, start a PostgreSQL container on your host network (or a custom network) for Liquibase to connect to.
docker run --name some-postgres -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres:latest - Now, try to run Liquibase with PostgreSQL (without the driver installed in the image). It will fail.
docker run --rm -v $(pwd):/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:postgresql://host.docker.internal:5432/postgres" \ -e LIQUIBASE_COMMAND_USERNAME="postgres" \ -e LIQUIBASE_COMMAND_PASSWORD="mysecretpassword" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \ -e LIQUIBASE_COMMAND_DRIVER="org.postgresql.Driver" \ liquibase/liquibase:4.33.0 \ update # This will likely fail with "Driver class not found" - Install the PostgreSQL driver on-the-fly with LPM (this is an example, often for dev/debug, not prod):Let’s instead switch to building a custom image directly for PostgreSQL.
# First, install LPM docker run --rm -v $(pwd):/liquibase/changelog liquibase/liquibase:4.33.0 lpm install --global postgres # Then, try to update. Note: this chaining is complex and ephemeral. Best to build custom image. # This particular chaining won't work as the driver install is in a *separate* ephemeral container. # This highlights why custom images are better. # To demonstrate LPM install for the *current run*, you'd need to extend the entrypoint # or have a script that does both. # For simplicity, let's assume you ran LPM locally and copied the drivers to /liquibase/drivers manually for this ephemeral demo: # (This is illustrative, and shows the *effect* not the best process.) # If you were to do this via script: # docker run --rm -v $(pwd):/liquibase/changelog liquibase/liquibase:4.33.0 /bin/bash -c "lpm install --global postgres && liquibase --defaults-file=/liquibase/changelog/liquibase.properties update"
- First, start a PostgreSQL container on your host network (or a custom network) for Liquibase to connect to.
Build Custom Docker Image with PostgreSQL Driver: Create
my-docker-project/Dockerfile:FROM liquibase/liquibase:4.33.0-alpine # Install PostgreSQL JDBC Driver # Check https://jdbc.postgresql.org/download/ for direct download link # Using Maven Central for a stable version: RUN mkdir -p /liquibase/drivers && \ wget -O /liquibase/drivers/postgresql-42.7.3.jar \ https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.3/postgresql-42.7.3.jar ENV LIQUIBASE_CLASSPATH=/liquibase/driversBuild the image:
cd my-docker-project docker build -t my-liquibase-postgres:4.33.0 .Run
updateusing this custom image against PostgreSQL:docker run --rm -v $(pwd):/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:postgresql://host.docker.internal:5432/postgres" \ -e LIQUIBASE_COMMAND_USERNAME="postgres" \ -e LIQUIBASE_COMMAND_PASSWORD="mysecretpassword" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \ -e LIQUIBASE_COMMAND_DRIVER="org.postgresql.Driver" \ my-liquibase-postgres:4.33.0 \ updateExpected Output: Success,
docker_test_tablecreated in PostgreSQL. This is the production-ready pattern.Using
--defaults-filewithin the container: Editmy-docker-project/liquibase.propertiesto include full PostgreSQL connection details:# my-docker-project/liquibase.properties url: jdbc:postgresql://host.docker.internal:5432/postgres driver: org.postgresql.Driver username: postgres password: mysecretpassword changelog-file: master.xmlNow run using the custom image and
--defaults-file:docker run --rm -v $(pwd):/liquibase/changelog \ my-liquibase-postgres:4.33.0 \ --defaults-file=/liquibase/changelog/liquibase.properties \ updateExpected Output: Successful update. This shows how
liquibase.propertiescan also be volume-mounted and used. For sensitive data, environment variables are still preferred for username/password.Clean up PostgreSQL container:
docker stop some-postgres docker rm some-postgres
Exercises (5+ per major section)
Assume you have a my-docker-labs directory.
Dockerized
statusfor H2:- Create
my-docker-labs/changelog.xmlwith onecreateTablechangeset for an H2 in-memory DB. - Create
my-docker-labs/liquibase.propertiesfor H2. - Run
liquibase updateusing the baseliquibase/liquibase:4.33.0-alpineimage and volume mounts/env vars. - Immediately after, run
liquibase statususing the same Docker command. What’s the output? - Now, modify
changelog.xmlto add a secondcreateTablechangeset. - Run
liquibase statusagain (without updating). What’s the new output? - Hint: The H2 DB persists as long as the Docker container (or the Liquibase Java process in it) is running. New containers start fresh unless configured otherwise. The
statuscommand reflects the currentDATABASECHANGELOGin the H2 DB. If you runupdate, it applies. If you rerunstatusin a new container, the H2 DB is fresh so it will show all changes pending. For this exercise, assume each command is a freshdocker run, so the H2 DB always starts clean.
- Create
Custom Image for MySQL:
- Start a MySQL container:
docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=mysecretpassword -e MYSQL_DATABASE=testdb -p 3306:3306 -d mysql:8.0. - Create
my-docker-labs/Dockerfilethat extendsliquibase/liquibase:4.33.0-alpineand adds the MySQL JDBC driver (mysql-connector-java-8.0.33.jar). - Build this image as
my-liquibase-mysql:latest. - Create
my-docker-labs/mysql-changelog.xml(a simplecreateTable). - Create
my-docker-labs/mysql.propertiesconfigured for the running MySQL container (usinghost.docker.internal:3306). - Run
liquibase updateusing yourmy-liquibase-mysql:latestimage, mounting your project folder, and pointing tomysql.properties. - Hint: Ensure
LIQUIBASE_CLASSPATHis set correctly in the Dockerfile.
- Start a MySQL container:
Docker Networking - Linux Host (Conceptual):
- Problem: You are running Docker on a Linux host, and your MySQL database is running directly on the host machine (not in a Docker container itself, or in another Docker container).
host.docker.internalis not available on Linux. - Task: Describe how you would configure the
urlinmysql.propertiesorLIQUIBASE_COMMAND_URLfor the Liquibase Docker container to connect to the host-bound MySQL. - Hint: Consider
--add-hostindocker runor Docker’s networking options for Linux.
- Problem: You are running Docker on a Linux host, and your MySQL database is running directly on the host machine (not in a Docker container itself, or in another Docker container).
Using LPM for Temporary Driver Installation in CI/CD (Advanced Concept):
- Scenario: In a CI/CD pipeline, for a very specific temporary test environment (e.g., a short-lived review app), you don’t want to build a custom Docker image for every possible database driver. You want to install a niche driver (e.g., for a legacy database) on-demand for a single Liquibase run.
- Task: Outline a shell script that, within a single
docker runcommand usingliquibase/liquibase:4.33.0-alpine, installs a hypotheticallegacy-drivervialpmand then immediately runsliquibase updateusing that newly installed driver. - Hint: You’ll need to chain commands within the
docker runand ensure theLIQUIBASE_CLASSPATHpoints to where LPM installed the driver. This often requires running/bin/bash -c "...".
Optimizing Docker Image Size for CI/CD:
- Compare the size of
liquibase/liquibase:4.33.0versusliquibase/liquibase:4.33.0-alpine. - Task: Explain why
alpineimages are generally preferred for production/CI/CD, and list two common techniques to further reduce the size of your custom Docker images (likemy-liquibase-mysql:latest). - Hint: Think about unnecessary files, multi-stage builds.
- Compare the size of
Solutions (Hints)
Dockerized
statusfor H2:- First
updaterun: Shows changeset 1 applied. docker run ... status: Shows0 changesets have not been applied...(assuming a fresh H2 container).- Add second changeset:
<!-- changelog.xml --> <changeSet id="2" author="docker_user"> <comment>Add another column</comment> <addColumn tableName="docker_test_table"> <column name="description" type="varchar(255)"/> </addColumn> </changeSet> - Second
docker run ... status: Shows1 changeset has not been applied...(changeset 2 is pending).
- First
Custom Image for MySQL:
Dockerfile:FROM liquibase/liquibase:4.33.0-alpine RUN mkdir -p /liquibase/drivers && \ wget -O /liquibase/drivers/mysql-connector-java-8.0.33.jar \ https://repo1.maven.org/maven2/mysql/mysql-connector-java/8.0.33/mysql-connector-java-8.0.33.jar ENV LIQUIBASE_CLASSPATH=/liquibase/driversmysql.properties:url: jdbc:mysql://host.docker.internal:3306/testdb(for macOS/Windows).docker build -t my-liquibase-mysql:latest .docker run --rm -v $(pwd):/liquibase/changelog -e LIQUIBASE_COMMAND_USERNAME=root -e LIQUIBASE_COMMAND_PASSWORD=mysecretpassword my-liquibase-mysql:latest --defaults-file=/liquibase/changelog/mysql.properties update
Docker Networking - Linux Host (Conceptual):
- Option 1:
--add-host:# If host IP is 172.17.0.1 docker run --rm --add-host "host.docker.internal:172.17.0.1" \ -v $(pwd):/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:mysql://host.docker.internal:3306/testdb" \ ... - Option 2: Docker Network (if MySQL is in another container):
docker network create my_db_netdocker run --name some-mysql --network my_db_net ... mysql:8.0docker run --rm --network my_db_net ... -e LIQUIBASE_COMMAND_URL="jdbc:mysql://some-mysql:3306/testdb" ... my-liquibase-mysql:latest
- Option 1:
Using LPM for Temporary Driver Installation in CI/CD (Advanced Concept):
docker run --rm \ -v $(pwd)/my-docker-labs:/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:legacy:db://..." \ -e LIQUIBASE_COMMAND_USERNAME="..." \ -e LIQUIBASE_COMMAND_PASSWORD="..." \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \ liquibase/liquibase:4.33.0-alpine \ /bin/bash -c "lpm install --global legacy-driver --version=1.0.0 && \ liquibase --defaults-file=/liquibase/changelog/liquibase.properties update"- Caveat:
lpm install --globalinstalls to/liquibase/lib. Liquibase automatically checks this.LIQUIBASE_CLASSPATHmight not be strictly needed here iflpmputs it in the defaultlibfolder.
- Caveat:
Optimizing Docker Image Size for CI/CD:
alpineimages are significantly smaller because they use Alpine Linux, a very lightweight distribution.- Techniques to reduce custom image size:
- Multi-stage Builds: Use one stage to build/download artifacts (e.g., JDBC drivers) and a separate, smaller runtime stage to copy only the necessary artifacts. This discards build tools, caches, etc.
- Remove build caches and temporary files: Add
&& rm -rf /var/cache/apk/* /tmp/*(for Alpine) or similar commands at the end ofRUNinstructions to clean up. - Choose smaller base image: Always prefer
-alpinevariants unless a specific dependency requires the largerstandardimage.
Pros and cons
Official Liquibase Docker Image
- Pros: Consistent environment, easy version management, isolated from host OS, simplifies dependencies.
- Cons: Basic image might lack specific JDBC drivers, requires understanding Docker concepts (volumes, networking).
Volume Mounts
- Pros: Easy to iterate on changelogs (changes on host immediately visible in container), no need to rebuild Docker image for changelog changes, simple to pass configuration files.
- Cons: Host filesystem needs to be accessible, permission issues can arise, potentially slower than built-in files for very large numbers of files.
Environment Variables
- Pros: Secure for sensitive data (not persisted in image layers or source control), easily managed by CI/CD systems, overrides
liquibase.properties. - Cons: Can be cumbersome to manage many variables, prone to typos.
LPM in Docker (on-the-fly)
- Pros: Flexible for installing drivers dynamically, useful for niche drivers or ephemeral dev environments.
- Cons: Adds startup time, driver not persistent across container runs, generally not suitable for production.
Custom Docker Images
- Pros: Production-ready, pre-configured with all necessary drivers, faster startup (no install step), versioned image, tailored for specific database stacks.
- Cons: Requires building and managing custom images, slightly more complex initial setup.
Common pitfalls and how to avoid them
- Incorrect Volume Mounts: Ensure the host path is correct and the container path (
/liquibase/changelog) matches expectations.$(pwd)is critical for dynamic host path. - Networking Issues: Remember
localhostin Docker !=localhoston host. Usehost.docker.internal(Desktop) or proper IP/Docker networking. - Missing Drivers: MySQL/MariaDB drivers are a common culprit. Always bake them into custom images for production.
- Hardcoding Passwords: Never embed passwords in Dockerfiles or
liquibase.propertiesthat are checked into source control. Use environment variables or secret mounts. - Ignoring Alpine vs. Standard: Using the larger standard image unnecessarily increases image size and potentially attack surface.
Troubleshooting and verification steps
- “Error: Driver class not found”: JDBC driver missing. Build a custom image or install via LPM (if temporary).
- “Connection Refused” / “Unknown Host”: Network issue. Check database URL, hostname (
host.docker.internal), port, firewall, and Docker network configuration. - “File Not Found” (for changelog/properties): Volume mount is incorrect or
changelog-file/--defaults-filepath inside the container is wrong. Usedocker execorlsin a temporary container to verify mount. - Container fails to start: Check Docker logs (
docker logs <container_id>). Could be entrypoint errors, permission issues, or resource constraints. - Verify Driver Installation in Image: After building a custom image, you can inspect it:This should list your
docker run --rm my-liquibase-postgres:4.33.0 ls -l /liquibase/driverspostgresql.jar(ormysql-connector.jar).
Takeaway checklist
- Can run Liquibase using the official Docker image with volume mounts.
- Can pass database credentials via Docker environment variables.
- Knows how to install custom JDBC drivers (e.g., MySQL) via a
Dockerfile. - Understands Docker networking for database connections.
- Can build a custom, optimized Liquibase Docker image for specific database needs.
- Aware of common Docker pitfalls and how to troubleshoot them.
12) CI/CD Integrations: GitHub Actions, GitLab CI, Maven/Gradle, secrets, environments
What you’ll learn
- Integrating Liquibase into automated CI/CD pipelines.
- Examples for GitHub Actions and GitLab CI.
- Managing secrets securely in CI/CD.
- Implementing environment promotion strategies.
- Brief overview of Maven/Gradle plugin usage for Java projects.
Why it matters
Automating database migrations in CI/CD is a cornerstone of DevOps. It ensures consistent, repeatable, and fast deployments, reducing manual errors and accelerating time to market. Securely handling credentials and defining a robust environment promotion strategy prevents unauthorized access and ensures that changes are thoroughly tested before reaching production.
Concepts explained simply
CI/CD for Liquibase means that when a developer commits a database change, automated tools take over to validate, test, and deploy it to various environments in a controlled manner.
- CI/CD Pipeline: A sequence of automated steps (stages/jobs) that build, test, and deploy code changes. For Liquibase, this means integrating database migration steps.
- GitHub Actions: GitHub’s native CI/CD platform. Workflows are defined in YAML files (
.github/workflows/*.yml). - GitLab CI: GitLab’s native CI/CD platform. Pipelines are defined in
.gitlab-ci.yml. - Secrets Management (CI/CD): Securely storing and injecting sensitive information (database passwords, API keys, Liquibase Pro license keys) into pipeline jobs without exposing them in logs or source code.
- GitHub Secrets: Stored in repository/organization settings.
- GitLab CI/CD Variables: Stored in project/group settings, marked as “protected” or “masked.”
- Environment Promotion Strategy: A defined process for moving database changes from lower environments (dev, QA) to higher environments (staging, production) after successful testing and approvals.
- Automated Deployment: Often fully automated for dev/QA.
- Gated Deployment: Requires manual approval steps for staging/production.
- Context-based Deployment: Using Liquibase
--contextsto apply environment-specific changes.
- Maven/Gradle Plugins: For Java projects, Liquibase offers plugins that integrate migration commands directly into the build process (e.g.,
mvn liquibase:update,gradle update). This ties database migrations to application builds.
UI navigation (if any) with “what is where” and step-by-step
- GitHub Actions:
- Secrets: Go to your GitHub repository ->
Settingstab ->Secrets and variables->Actions->Repository secrets. - Workflows: In your repo, click the
Actionstab. You’ll see workflow runs, their status, and logs. Clicking on a run shows job details.
- Secrets: Go to your GitHub repository ->
- GitLab CI:
- CI/CD Variables: In your GitLab project ->
Settings->CI/CD->Variables. - Pipelines: In your GitLab project ->
Build->Pipelines. Shows pipeline runs, stages, jobs, and logs.
- CI/CD Variables: In your GitLab project ->
Step-by-step lab(s) with copy-pasteable commands and expected output
We’ll use our my-ci-cd-project setup.
Setup:
my-ci-cd-project/liquibase.properties:
changelog-file: master.xml
# No sensitive info here, relying on CI/CD secrets
my-ci-cd-project/master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="ci_cd_user">
<comment>Create audit_log table</comment>
<createTable tableName="audit_log">
<column name="id" type="SERIAL"> <constraints primaryKey="true" nullable="false"/> </column>
<column name="event_name" type="varchar(255)"/>
<column name="event_timestamp" type="timestamp with time zone" defaultValueComputed="NOW()"/>
<column name="environment" type="varchar(50)"/>
</createTable>
</changeSet>
<changeSet id="2" author="ci_cd_user" contexts="dev, staging">
<comment>Add developer specific debug table</comment>
<createTable tableName="debug_data">
<column name="id" type="SERIAL"> <constraints primaryKey="true" nullable="false"/> </column>
<column name="debug_message" type="text"/>
</createTable>
</changeSet>
<changeSet id="3" author="ci_cd_user" contexts="prod">
<comment>Add critical configuration table for production</comment>
<createTable tableName="prod_configs">
<column name="config_key" type="varchar(100)"> <constraints primaryKey="true"/> </column>
<column name="config_value" type="text"/>
</createTable>
</changeSet>
</databaseChangeLog>
Assume you have PostgreSQL instances for dev, staging, prod (or simulate them with H2 by changing connection URLs).
Lab: CI/CD Integrations
GitHub Actions Workflow (
.github/workflows/liquibase.yml):name: Liquibase Migrations on: push: branches: - main workflow_dispatch: # Allows manual trigger jobs: dev-migration: runs-on: ubuntu-latest environment: dev # Reference GitHub Environment (for secrets) env: # Liquibase expects LIQUIBASE_COMMAND_URL, etc. LIQUIBASE_COMMAND_URL: ${{ secrets.DEV_DB_URL }} LIQUIBASE_COMMAND_USERNAME: ${{ secrets.DEV_DB_USERNAME }} LIQUIBASE_COMMAND_PASSWORD: ${{ secrets.DEV_DB_PASSWORD }} # Pro license key if using Pro features LIQUIBASE_PRO_LICENSE_KEY: ${{ secrets.LIQUIBASE_PRO_LICENSE_KEY }} steps: - uses: actions/checkout@v4 - name: Set up Java uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - name: Install Liquibase (via npm for simplicity or download zip) run: | npm install -g liquibase # Or download from liquibase.com/download and add to PATH echo "/opt/hostedtoolcache/node/18.16.0/x64/lib/node_modules/liquibase/bin" >> $GITHUB_PATH # Adjust path as needed for npm install location - name: Liquibase Update for DEV environment run: | liquibase --defaults-file=liquibase.properties --contexts=dev update working-directory: my-ci-cd-project # This step will run changesets with no context and 'dev' context - name: Liquibase Status for DEV run: liquibase --defaults-file=liquibase.properties --contexts=dev status --verbose working-directory: my-ci-cd-project staging-migration: needs: dev-migration # Only run after dev is successful runs-on: ubuntu-latest environment: staging # Reference GitHub Environment env: LIQUIBASE_COMMAND_URL: ${{ secrets.STAGING_DB_URL }} LIQUIBASE_COMMAND_USERNAME: ${{ secrets.STAGING_DB_USERNAME }} LIQUIBASE_COMMAND_PASSWORD: ${{ secrets.STAGING_DB_PASSWORD }} LIQUIBASE_PRO_LICENSE_KEY: ${{ secrets.LIQUIBASE_PRO_LICENSE_KEY }} steps: - uses: actions/checkout@v4 - name: Set up Java uses: actions/setup-java@v4 with: { distribution: 'temurin', java-version: '17' } - name: Install Liquibase run: | npm install -g liquibase echo "/opt/hostedtoolcache/node/18.16.0/x64/lib/node_modules/liquibase/bin" >> $GITHUB_PATH - name: Liquibase UpdateSQL for STAGING (Dry Run) run: | liquibase --defaults-file=liquibase.properties --contexts=staging updateSQL > staging-migrations.sql working-directory: my-ci-cd-project - name: Display Staging SQL run: cat staging-migrations.sql - name: Liquibase Update for STAGING environment run: liquibase --defaults-file=liquibase.properties --contexts=staging update working-directory: my-ci-cd-project prod-migration: needs: staging-migration runs-on: ubuntu-latest environment: production # Reference GitHub Environment (requires manual approval) env: LIQUIBASE_COMMAND_URL: ${{ secrets.PROD_DB_URL }} LIQUIBASE_COMMAND_USERNAME: ${{ secrets.PROD_DB_USERNAME }} LIQUIBASE_COMMAND_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }} LIQUIBASE_PRO_LICENSE_KEY: ${{ secrets.LIQUIBASE_PRO_LICENSE_KEY }} steps: - uses: actions/checkout@v4 - name: Set up Java uses: actions/setup-java@v4 with: { distribution: 'temurin', java-version: '17' } - name: Install Liquibase run: | npm install -g liquibase echo "/opt/hostedtoolcache/node/18.16.0/x64/lib/node_modules/liquibase/bin" >> $GITHUB_PATH - name: Liquibase UpdateSQL for PROD (Dry Run and Approval) run: | liquibase --defaults-file=liquibase.properties --contexts=prod updateSQL > prod-migrations.sql working-directory: my-ci-cd-project - name: Upload Prod SQL Artifact uses: actions/upload-artifact@v4 with: name: prod-sql-script path: my-ci-cd-project/prod-migrations.sql - name: Manual Approval for Production # This step will only run if a human approves the 'production' environment # In GitHub, this requires configuring protection rules for the 'production' environment. run: echo "Waiting for manual approval before deploying to production..." - name: Liquibase Update for PROD environment run: liquibase --defaults-file=liquibase.properties --contexts=prod update working-directory: my-ci-cd-project- Secrets: You need to define
DEV_DB_URL,DEV_DB_USERNAME,DEV_DB_PASSWORD,STAGING_DB_URL, etc., andLIQUIBASE_PRO_LICENSE_KEYin GitHub repository secrets. - GitHub Environments: Configure
dev,staging,productionenvironments in your repository settings (under “Environments”). Forproduction, add “Required reviewers” to enforce manual approval.
- Secrets: You need to define
GitLab CI/CD Pipeline (
.gitlab-ci.yml):image: liquibase/liquibase:4.33.0-alpine # Use custom image if drivers needed variables: # Use GitLab CI/CD variables (Settings -> CI/CD -> Variables) # Mark as "Protected" and "Masked" LIQUIBASE_COMMAND_CHANGELOG_FILE: master.xml stages: - validate - deploy_dev - deploy_staging - deploy_prod # Stage 1: Validate changelogs (Pro feature with Policy Checks) validate_changes: stage: validate script: - liquibase --defaults-file=liquibase.properties validate # For Liquibase Pro, you'd add LIQUIBASE_PRO_LICENSE_KEY as a variable here. # And potentially other policy check config. allow_failure: false # Fail the pipeline if validation fails # Stage 2: Deploy to Development deploy_dev: stage: deploy_dev before_script: # If not using a custom Liquibase image, install PostgreSQL driver here - apk add --no-cache curl unzip openjdk17 - wget -q -O /usr/share/liquibase/lib/postgresql-42.7.3.jar https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.3/postgresql-42.7.3.jar script: - liquibase --defaults-file=liquibase.properties --contexts=dev update environment: name: development only: - main # Only run on main branch variables: # Override global LIQUIBASE_COMMAND_... variables for this job LIQUIBASE_COMMAND_URL: $DEV_DB_URL LIQUIBASE_COMMAND_USERNAME: $DEV_DB_USERNAME LIQUIBASE_COMMAND_PASSWORD: $DEV_DB_PASSWORD needs: ["validate_changes"] # Stage 3: Deploy to Staging deploy_staging: stage: deploy_staging before_script: - apk add --no-cache curl unzip openjdk17 - wget -q -O /usr/share/liquibase/lib/postgresql-42.7.3.jar https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.3/postgresql-42.7.3.jar script: - liquibase --defaults-file=liquibase.properties --contexts=staging updateSQL > staging_migrations.sql - cat staging_migrations.sql - liquibase --defaults-file=liquibase.properties --contexts=staging update environment: name: staging rules: - if: '$CI_COMMIT_BRANCH == "main"' # Only deploy to staging from main when: manual # Requires manual trigger for staging variables: LIQUIBASE_COMMAND_URL: $STAGING_DB_URL LIQUIBASE_COMMAND_USERNAME: $STAGING_DB_USERNAME LIQUIBASE_COMMAND_PASSWORD: $STAGING_DB_PASSWORD needs: ["deploy_dev"] # Stage 4: Deploy to Production deploy_prod: stage: deploy_prod before_script: - apk add --no-cache curl unzip openjdk17 - wget -q -O /usr/share/liquibase/lib/postgresql-42.7.3.jar https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.3/postgresql-42.7.3.jar script: - liquibase --defaults-file=liquibase.properties --contexts=prod updateSQL > prod_migrations.sql - cat prod_migrations.sql - liquibase --defaults-file=liquibase.properties --contexts=prod update environment: name: production rules: - if: '$CI_COMMIT_BRANCH == "main"' when: manual # Requires manual trigger for production variables: LIQUIBASE_COMMAND_URL: $PROD_DB_URL LIQUIBASE_COMMAND_USERNAME: $PROD_DB_USERNAME LIQUIBASE_COMMAND_PASSWORD: $PROD_DB_PASSWORD needs: ["deploy_staging"] # GitLab environments can also have protection rules for manual approval.- GitLab CI/CD Variables: Define
DEV_DB_URL,DEV_DB_USERNAME,DEV_DB_PASSWORD, etc., as “Protected” and “Masked” variables in your GitLab project.
- GitLab CI/CD Variables: Define
Maven Plugin (Brief Overview): In a Java project’s
pom.xml:<build> <plugins> <plugin> <groupId>org.liquibase</groupId> <artifactId>liquibase-maven-plugin</artifactId> <version>4.33.0</version> <configuration> <changeLogFile>src/main/resources/master.xml</changeLogFile> <url>${db.url}</url> <username>${db.username}</username> <password>${db.password}</password> <!-- Add contexts, labels, etc. --> <contexts>${liquibase.contexts}</contexts> <driver>${db.driver}</driver> </configuration> <executions> <execution> <phase>process-resources</phase> <!-- Or other phase --> <goals> <goal>update</goal> </goals> </execution> </executions> <dependencies> <!-- Add your database specific JDBC driver --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <version>42.7.3</version> </dependency> </dependencies> </plugin> </plugins> </build>You would then run
mvn liquibase:update(or justmvn installif bound to a phase) withdb.url,db.username, etc., typically passed as Maven properties (-Ddb.url=...) or fromsettings.xml.
Exercises (5+ per major section)
GitHub Actions - Implement Policy Check:
- Modify the
dev-migrationjob in the GitHub Actions workflow to runliquibase validate(assuming Pro policies are configured) beforeliquibase update. - Ensure the
validatestep fails the job if any policy violations ofFAILseverity are found. - Hint: The
validatecommand exits with a non-zero code on failure, which causes GitHub Actions to fail the step.
- Modify the
GitLab CI - Manual Approval for Staging:
- Verify the
deploy_stagingjob in the GitLab CI example. - Task: Describe how a developer would manually trigger this job in the GitLab UI, and how they would verify the
staging_migrations.sqlartifact before approving. - Hint: GitLab’s UI provides “play” buttons for manual jobs and artifact browsing.
- Verify the
CI/CD Secrets Handling - Liquibase Pro Key:
- Both GitHub Actions and GitLab CI examples use
LIQUIBASE_PRO_LICENSE_KEY. - Task: Explain the importance of marking this secret as “masked” in GitLab and using
secrets.<KEY_NAME>in GitHub Actions. Why is it a bad idea to justecho $LIQUIBASE_PRO_LICENSE_KEYin a pipeline script? - Hint: Masking prevents sensitive values from appearing in logs.
- Both GitHub Actions and GitLab CI examples use
Environment Promotion -
updateSQLand Gated Deployments:- The
prod-migrationjobs in both GitHub and GitLab pipelines includeupdateSQLbefore the actualupdate. - Task: Explain the purpose of
updateSQLin the production deployment stage, especially in conjunction with manual approvals. How does this enhance safety? - Hint: Dry runs are critical for production.
- The
Maven/Gradle - Profile-based Configuration:
- Problem: In a Maven project, you want to use different database configurations (URL, username, password) for
dev,test, andprodenvironments without modifyingpom.xmlfor each deployment. - Task: Outline how you would use Maven
profilesto manage these environment-specific Liquibase configurations. - Hint: Maven profiles allow you to define different sets of properties or plugin configurations that can be activated by a command-line flag (e.g.,
-Pprod).
- Problem: In a Maven project, you want to use different database configurations (URL, username, password) for
Solutions (Hints)
Environment Promotion -
updateSQLand Gated Deployments:- Purpose:
updateSQLgenerates the SQL script that Liquibase would execute, but doesn’t run it against the database. - Safety Enhancement: In a production deployment:
- The
updateSQLstep creates a complete, ordered script of all DDL/DML changes that are about to be applied. - This script is then typically reviewed by a human DBA or a senior engineer (during a manual approval step in the CI/CD environment). They can analyze the exact SQL, estimate its impact, check for performance issues, and ensure it aligns with operational requirements.
- Only after this review and explicit approval (e.g., clicking a “Deploy to Prod” button) does the actual
liquibase updatecommand run.
- The
- This provides a crucial “human in the loop” gate, preventing unintended or risky changes from being automatically applied to critical production databases. It acts as a final verification step.
- Purpose:
Maven/Gradle - Profile-based Configuration:
- Maven Profiles:
In
pom.xml:<project> <!-- ... --> <properties> <env.db.url>jdbc:postgresql://localhost:5432/devdb</env.db.url> <env.db.username>devuser</env.db.username> <env.db.password>devpass</env.db.password> </properties> <build> <plugins> <plugin> <groupId>org.liquibase</groupId> <artifactId>liquibase-maven-plugin</artifactId> <version>4.33.0</version> <configuration> <changeLogFile>src/main/resources/master.xml</changeLogFile> <url>${env.db.url}</url> <username>${env.db.username}</username> <password>${env.db.password}</password> <driver>org.postgresql.Driver</driver> <contexts>${liquibase.contexts}</contexts> </configuration> <dependencies>...</dependencies> </plugin> </plugins> </build> <profiles> <profile> <id>test</id> <properties> <env.db.url>jdbc:postgresql://test-db-host:5432/testdb</env.db.url> <env.db.username>testuser</env.db.username> <env.db.password>${env.db.test_password}</env.db.password> <!-- From CI secret --> <liquibase.contexts>test</liquibase.contexts> </properties> </profile> <profile> <id>prod</id> <properties> <env.db.url>jdbc:postgresql://prod-db-host:5432/proddb</env.db.url> <env.db.username>produser</env.db.username> <env.db.password>${env.db.prod_password}</env.db.password> <!-- From CI secret --> <liquibase.contexts>prod</liquibase.contexts> </properties> </profile> </profiles> <!-- ... --> </project> - Usage:
mvn liquibase:update(uses default properties for dev)mvn liquibase:update -Ptest(activatestestprofile, usestestDB config)mvn liquibase:update -Pprod(activatesprodprofile, usesprodDB config)
- For sensitive passwords (
env.db.test_password,env.db.prod_password), these would be passed via CI/CD environment variables or from Maven’ssettings.xml(encrypted) to avoid hardcoding inpom.xml.
- Maven Profiles:
In
Pros and cons
CI/CD Automation (General)
- Pros: Consistency, repeatability, speed, reduces human error, forces version control of database changes, faster feedback loops.
- Cons: Requires initial setup effort, potential for pipeline complexity, debugging CI/CD issues can be challenging.
GitHub Actions / GitLab CI
- Pros: Native integration with respective SCM platforms, strong ecosystem, YAML-based definitions, good secrets management, built-in environment support.
- Cons: Vendor lock-in (to an extent), YAML syntax can be particular, limited to features of the specific platform.
Secrets Management (CI/CD Variables / GitHub Secrets)
- Pros: Essential security practice, keeps credentials out of source code and logs, centralizes secret storage.
- Cons: Requires careful configuration (masking, protection), access control to secrets must be strict.
Environment Promotion Strategy
- Pros: Structured deployment process, ensures testing in lower environments before higher ones, enables gates and approvals for critical environments.
- Cons: Can slow down deployments if gates are excessive, requires careful design to avoid bottlenecks.
Maven/Gradle Plugins
- Pros: Tightly integrates Liquibase with the application build process, convenient for Java developers, leverages existing build tool knowledge.
- Cons: Adds a dependency to the application build, might require specific version compatibility with the build tool and Liquibase.
Common pitfalls and how to avoid them
- Hardcoding Passwords in Pipelines: Never directly embed
DB_PASSWORD=mysecretin your.gitlab-ci.ymlor GitHub Actions YAML. Always use the platform’s secrets management. - Lack of
updateSQL/ Dry Runs: Deploying directly to production without aupdateSQLreview is extremely risky. Always generate the SQL, review, and get approval for critical environments. - Missing Environment-Specific Contexts: Forgetting to pass
--contexts=prod(or similar) can lead to unintended changes (e.g., test data inserted into production) or missing critical configurations. - Inconsistent Liquibase Versions: Ensure all CI/CD jobs (and local dev environments) use the same Liquibase version to prevent unexpected behavior. Use specific Docker image tags or explicit version downloads.
- Ignoring Network Configuration in Docker: If your database isn’t
host.docker.internalor in the same Docker network, connection failures will occur.
Troubleshooting and verification steps
- Pipeline fails on database connection:
- Check environment variables (
LIQUIBASE_COMMAND_URL,USERNAME,PASSWORD) for typos or incorrect values. - Verify the database host is reachable from the CI/CD runner (firewall rules, network configuration).
- Test credentials manually if possible (e.g., using
psqlormysqlclient from the runner).
- Check environment variables (
- Liquibase command fails (e.g.,
updateexits with error):- Examine the pipeline logs carefully for the exact Liquibase error message.
- Temporarily increase
logLevel: DEBUGinliquibase.propertiesor on the command line (--log-level=DEBUG) for more verbose output. - Recreate the issue locally using the exact same Liquibase version and configuration.
- Changes applied to wrong environment:
- Verify the
--contextsparameter is correctly applied in the CI/CD job. - Ensure the
LIQUIBASE_COMMAND_URL(or equivalent) points to the correct database instance for that environment. - Double-check that the environment variables are securely isolated between jobs/stages.
- Verify the
- Manual approval step not working:
- Check GitHub Environment protection rules or GitLab
rules:when: manualconfiguration. - Ensure the user trying to approve has the necessary permissions.
- Check GitHub Environment protection rules or GitLab
Takeaway checklist
- Can integrate
liquibase updateinto GitHub Actions and GitLab CI workflows. - Understands how to securely manage database credentials using CI/CD secrets.
- Implemented a basic environment promotion strategy (dev → staging → prod).
- Utilizes
updateSQLfor dry runs and approval gates for production. - Familiar with basic Maven/Gradle Liquibase plugin usage.
- Understands
--contextsfor environment-specific changes in CI/CD.
13) Team Practices: Branching, code review, avoiding conflicts, handling large changes
What you’ll learn
- Effective Git branching strategies for database changelogs.
- Best practices for code reviews of Liquibase changesets.
- Strategies to avoid and resolve common conflicts (ID, checksum).
- Approaches for managing and deploying large-scale database changes.
Why it matters
As teams grow and accelerate, managing database changes becomes a bottleneck if not handled collaboratively. Proper team practices ensure that multiple developers can work on the database simultaneously without stepping on each other’s toes, leading to merge conflicts, or introducing errors. Clear processes for branching, review, and conflict resolution are vital for maintaining velocity and stability.
Concepts explained simply
Just like application code, database schema changes need to go through version control (Git) and code review. This section focuses on the “human” and “process” aspects of working with Liquibase in a team.
- Branching Strategies:
- Feature Branches (GitFlow, GitHub Flow): Each new feature or bug fix gets its own branch. Liquibase changelogs are developed in these branches and merged back to
main/developlike application code. - Short-lived Branches: Encourage frequent merges to reduce drift and complex merge conflicts.
- Feature Branches (GitFlow, GitHub Flow): Each new feature or bug fix gets its own branch. Liquibase changelogs are developed in these branches and merged back to
- Code Review for Changelogs:
- What to look for: Correctness of DDL/DML, idempotency, potential performance impact, proper rollback statements, comments, contexts, labels, adherence to naming conventions, security implications.
- Mandatory Review: All database changesets should be reviewed by at least one other team member (preferably a senior developer or DBA).
- Avoiding Conflicts:
- Changeset IDs: Use UUIDs, descriptive IDs (
feature-x-create-table), or a date-based prefix (20251001-01_description) rather than simple sequential numbers (e.g.,id="1") within the same master changelog, especially when multiple developers modify the same file or are likely to modify files that get included byincludeAllinto the same logical path. Combiningauthor:idwithlogicalFilePathis the ultimate unique identifier. logicalFilePath: As discussed, use explicitlogicalFilePathor rely on unique folder paths for modules to ensure uniqueness inDATABASECHANGELOG.- Master Changelog Merges: When developers add new
includestatements to a shared master changelog, merge conflicts are common. UsingincludeAllfor well-structured subdirectories can reduce direct merges in the master.
- Changeset IDs: Use UUIDs, descriptive IDs (
- Resolving Conflicts:
- Git Merge Conflicts: Standard Git resolution. For changelogs, ensure the final merged order is logical.
- Checksum Conflicts: Occur when an already-applied changeset’s content (or identifying attributes) changes. Never resolve by changing the
DATABASECHANGELOGtable in production.- Development/Test:
changelog-sync-force <changeset_id>after verifying the actual change is safe and intended. - Production: Fix-forward with a new changeset, or revert the change in source control if it’s truly problematic before deployment.
- Development/Test:
- Handling Large Changes:
- Phased Rollouts / Blue-Green / Canary: For very large or risky schema changes (e.g., non-nullable column addition on a huge table), break them into smaller, reversible steps.
- Dark Launching / Feature Flags: Decouple deployment of the schema change from application code enabling it.
- Performance Testing: Run performance tests with the new schema changes in a pre-production environment.
Step-by-step lab(s) with copy-pasteable commands and expected output
Setup: A shared Git repository. You’ll simulate two developers (dev1, dev2) working concurrently.
shared-project/master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<includeAll path="db/releases" relativeToChangelogFile="true"/>
</databaseChangeLog>
shared-project/db/releases/R20251001_initial_schema.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="system">
<createTable tableName="customers">
<column name="id" type="SERIAL"> <constraints primaryKey="true"/> </column>
<column name="name" type="varchar(255)"/>
</createTable>
</changeSet>
</databaseChangeLog>
shared-project/liquibase.properties (H2 in-memory).
Lab: Team Collaboration & Conflict Resolution
Initial Setup and Merge Base:
git initinshared-project.- Add
master.xml,R20251001_initial_schema.xml,liquibase.properties. git add . && git commit -m "Initial schema and project setup".- Create a clean H2 database and run
liquibase update.
Developer 1 (dev1) - Feature Branch:
git checkout -b feature/dev1-add-orders- Create
db/releases/R20251002_dev1_add_orders.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog ...> <changeSet id="1" author="dev1"> <createTable tableName="orders"> <column name="order_id" type="SERIAL"> <constraints primaryKey="true"/> </column> <column name="customer_id" type="int"/> <column name="order_date" type="timestamp" defaultValueComputed="NOW()"/> </createTable> </changeSet> </databaseChangeLog> git add . && git commit -m "feat: Add orders table (dev1)"- Run
liquibase update(on dev1’s local H2) to verify.
Developer 2 (dev2) - Concurrent Feature Branch:
git checkout maingit checkout -b feature/dev2-add-products- Create
db/releases/R20251002_dev2_add_products.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog ...> <changeSet id="1" author="dev2"> <createTable tableName="products"> <column name="product_id" type="SERIAL"> <constraints primaryKey="true"/> </column> <column name="name" type="varchar(255)"/> </createTable> </changeSet> </databaseChangeLog> git add . && git commit -m "feat: Add products table (dev2)"- Run
liquibase update(on dev2’s local H2) to verify.
Simulate Merge Conflict -
master.xml(Conceptual,includeAllmitigates this usually): Ifmaster.xmlused explicit<include>tags, and bothdev1anddev2added their<include>to the same line (e.g., at the end ofmaster.xml), a standard Git merge conflict would occur. WithincludeAll, this is less likely for new files, but could happen if existing files are modified simultaneously.Merge
dev1tomain:git checkout maingit merge feature/dev1-add-orders(should be a fast-forward or clean merge).- Run
liquibase update(on the shared dev DB or local main branch H2). Bothcustomersandorderstables should be present.
Merge
dev2tomain(Potential Conflict Resolution):git checkout maingit merge feature/dev2-add-products- Result: This should merge cleanly as
includeAllorders by filename. Ifmaster.xmlhad been manually edited by both, a Git conflict would appear inmaster.xml. - Run
liquibase update. All three tables (customers,orders,products) should be present.
Simulate Checksum Conflict (Developer Mistake):
git checkout -b feature/bad-fix main- Make a bad change to an already applied changeset: Edit
R20251001_initial_schema.xmlto change thenamecolumn tofirst_name.<!-- R20251001_initial_schema.xml --> <changeSet id="1" author="system"> <createTable tableName="customers"> <column name="id" type="SERIAL"> <constraints primaryKey="true"/> </column> <column name="first_name" type="varchar(255)"/> <!-- CHANGED THIS --> </createTable> </changeSet> - Run
liquibase update. Expected Output:
Liquibase command failed with exception: Validation Error: The file db/releases/R20251001_initial_schema.xml::1::system has changed since it was run at 2025-10-01 16:00:00. Expected checksum was: 8:12345abcdef... but actual is: 8:67890ghijkl...- Resolution (Dev environment):
- Option A (Fix-Forward): Revert the local file change. Create a new changeset to add a
first_namecolumn and dropname(with data migration if needed). This is safest. - Option B (Dangerous, Dev-Only
changelog-sync-force): If you’re certain this is the correct schema for your development environment and the checksum change is benign (e.g., cosmetic), you can force a sync.WARNING: This is only for development or highly controlled situations where you are absolutely certain of the consequence. Never do this in production without an equivalent “known good” backup/restore strategy.liquibase --defaults-file=liquibase.properties changelog-sync-force R20251001_initial_schema.xml::1::system liquibase --defaults-file=liquibase.properties update # Will now pass
- Option A (Fix-Forward): Revert the local file change. Create a new changeset to add a
Exercises (5+ per major section)
GitFlow for Features:
- Outline a GitFlow-like branching strategy for Liquibase changes:
main(stable),develop(integration), andfeature/Xbranches. - Describe the typical workflow for a new feature (adding tables, columns) from a
featurebranch todevelopand thenmain, including Liquibaseupdateverification at each stage. - Hint: Focus on merge points and which Liquibase commands (
update,status) are run.
- Outline a GitFlow-like branching strategy for Liquibase changes:
Code Review Checklist for Liquibase Changesets:
- Create a markdown checklist for reviewing Liquibase changesets.
- Include at least 5 key items a reviewer should check, such as:
- Uniqueness of
id/author/logicalFilePath. - Presence of
comment. - Correctness and safety of
rollbackblocks. - Idempotency of
runAlwayschangesets. - Potential performance impact (e.g., large
ALTER TABLE).
- Uniqueness of
- Hint: Think about what could go wrong if these items are missed.
Preventing ID Collisions with Naming Conventions:
- Imagine two developers simultaneously create a file named
db/new_feature.xmlin their respective feature branches, both containing a changeset withid="1" author="dev_initials". They will get differentlogicalFilePathif the parent directories are distinct, but a future merge could still be messy if the files end up in the same path. - Task: Propose a robust naming convention for changelog files and changesets
ids within those files that minimizes the chance of future conflicts, even if developers create files with similar names or move them. - Hint: Date prefixes, JIRA IDs, descriptive IDs, and careful
logicalFilePathsetting.
- Imagine two developers simultaneously create a file named
Handling a Large, Risky Change (Conceptual):
- Scenario: You need to add a non-nullable
tenant_idcolumn to acustomer_transactionstable with billions of rows. This change is irreversible without data loss if not planned correctly. - Task: Outline a multi-step Liquibase strategy to perform this migration safely in a phased manner, ensuring reversibility at each step.
- Hint: Think about adding nullable column first, backfilling data, then making it non-nullable.
- Scenario: You need to add a non-nullable
Resolving a Production Checksum Conflict (Conceptual):
- Scenario: A DBA made an emergency manual change directly in the production database (e.g., added a temporary index for performance). This was done outside Liquibase. Now,
liquibase updatefails with a checksum error because it detected the change (or a subsequent Liquibase changeset expected it to not be there). - Task: Describe the safest “fix-forward” approach to resolve this in production without using
changelog-sync-force. What Liquibase command would be used to reconcile the changelog with the existing manual change? - Hint: The
diff-changelogcommand is key for capturing existing schema.
- Scenario: A DBA made an emergency manual change directly in the production database (e.g., added a temporary index for performance). This was done outside Liquibase. Now,
Solutions (Hints)
GitFlow for Features:
- Workflow:
mainbranch: Reflects production. Only hotfixes merge directly.developbranch: Integration branch for new features. Allfeature/*branches merge here.feature/my-awesome-featurebranch: Developer works here.- Adds
db/features/my-feature/V1_create_table.xml. - Runs
liquibase updateon local/dev H2. - Regularly rebases/merges
developintofeatureto stay up-to-date.
- Adds
- Merge
featuretodevelop: Create a Pull Request (PR). Code review occurs. CI/CD runsliquibase update --contexts=devagainst a dedicateddevdatabase. On success, merge todevelop. - Release Process: When
developis stable for a release, create arelease/X.Ybranch. Runliquibase update --contexts=stagingagainst astagingdatabase. After testing and approval, mergerelease/X.Ytomain. maindeployment:liquibase update --contexts=prodto production. Tag the database (liquibase tag vX.Y).
- Verification:
liquibase statusbeforeupdateto see what’s pending.liquibase historyafterupdateto confirm.
- Workflow:
Code Review Checklist for Liquibase Changesets:
### Liquibase Changelog Code Review Checklist **General:** - [ ] `id`, `author`, `logicalFilePath` are unique across the project and stable. - [ ] Changeset includes a meaningful `<comment>` explaining its purpose. - [ ] Changeset size is atomic (does one thing well). - [ ] Appropriate `contexts` and `labels` are applied (if needed). **Rollbacks & Idempotency:** - [ ] `rollback` block is present and accurate for non-auto-rollbackable or data-modifying changes. - [ ] `rollback` logic is safe (e.g., avoids data loss, doesn't break dependent objects). - [ ] If `runAlways="true"`, the changeset is truly idempotent. **Schema & Data:** - [ ] DDL/DML is syntactically correct for all target database types (if using abstractions, cross-DB tested). - [ ] Naming conventions (tables, columns, indexes) are followed. - [ ] Data types chosen are appropriate and efficient. - [ ] Constraints (PK, FK, unique, NOT NULL) are correctly applied. - [ ] Potential performance impact on large tables (e.g., index creation, column additions) is considered. **Security & Best Practices:** - [ ] No hardcoded sensitive data (passwords, API keys). - [ ] No overly broad grants (e.g., `GRANT ALL TO PUBLIC`). - [ ] Uses preconditions for safety where applicable (e.g., `tableExists`, `columnExists`). - [ ] Change is necessary and aligns with architectural principles.Preventing ID Collisions with Naming Conventions:
- Changelog File Naming:
YYYYMMDDHHMM-FeatureName-Description.xml(e.g.,202510011530-PROJ123-AddCustomerFields.xml). The timestamp makes the filename unique and ensuresincludeAllorders correctly. - Changeset
id:- Option 1 (Descriptive + Author):
id="add-email-column" author="dev_initials". Combine with uniquelogicalFilePaththis is unique. - Option 2 (UUIDs):
id="a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" author="dev_initials". Guarantees global uniqueness. - Option 3 (File-local sequential if file is truly atomic): If a changelog file represents a single, self-contained migration unit (
R20251001_initial_schema.xml), thenid="1" author="system"is fine within that file.
- Option 1 (Descriptive + Author):
- Explicit
logicalFilePath: Always setlogicalFilePathto a logical, stable path for each changeset, especially if files might move. E.g.,logicalFilePath="proj/customer/schema.xml".
- Changelog File Naming:
Handling a Large, Risky Change (Conceptual):
- Phase 1: Add Nullable Column:
- Changeset 1:
addColumntenant_id(UUID, nullable) tocustomer_transactions. AddrollbacktodropColumn. - Deploy to production. Application continues to work.
- Changeset 1:
- Phase 2: Backfill Data:
- Changeset 2: A data migration changeset (e.g.,
sqltag runningUPDATE customer_transactions SET tenant_id = <default_value> WHERE tenant_id IS NULL;or logic to derivetenant_id). Make this changesetrunOnceor apreConditionto ensure it only runs iftenant_idis null. - Deploy. This might take time on large tables. Monitor performance.
- Changeset 2: A data migration changeset (e.g.,
- Phase 3: Update Application & Make Column Non-Nullable:
- Update application code to use the new
tenant_id(and enforce it). - Changeset 3: A
setColumnRemarksoraddNotNullConstraint(Liquibase tag) ontenant_id. - Deploy to production after application code is deployed and verified.
- Update application code to use the new
- Reversibility: At each step, a rollback is possible. If data backfill fails, revert to Phase 1. If application code update fails, revert application, then Phase 2 or 1 as needed.
- Phase 1: Add Nullable Column:
Resolving a Production Checksum Conflict (Conceptual):
- Problem:
liquibase updatefails ondb/releases/R20251001_initial_schema.xml::1::systembecause thenamecolumn was manually changed tofirst_namein prod. - Safest Fix-Forward:
- Do NOT edit
R20251001_initial_schema.xmlin source control. Keep it asname. - Generate a drift changeset: Connect Liquibase to a clone of the production database (or use
diff-changelogfrom a temporary snapshot of production). Runliquibase diff-changelog --output-file=db/hotfix/R20251001_prod_rename_name_to_firstname.xml. This will generate a changeset torenameColumnfromnametofirst_name. - Review and Include: Review
db/hotfix/R20251001_prod_rename_name_to_firstname.xml. It should look like:Include this new file in your<changeSet id="generated-timestamp" author="generated-hotfix"> <renameColumn tableName="customers" oldColumnName="name" newColumnName="first_name"/> </changeSet>master.xmlafterR20251001_initial_schema.xml. - Deploy: Run
liquibase update. Sincenamewas already renamed tofirst_namein production, this new changeset will find thenamecolumn missing (which is good) and effectively applyrenameColumn name to first_name, but it will probably detect thatfirst_namealready exists. This approach still captures the manual change. The key is thatDATABASECHANGELOGrecords that this specific change was run. - Alternative (Pro only): Liquibase Pro’s advanced drift management might offer a “Adopt Drift” feature in Hub which directly records the drift as part of the changelog history without you manually running
diff-changelog.
- Do NOT edit
- Problem:
Pros and cons
Feature Branches (GitFlow / GitHub Flow)
- Pros: Isolated development, easy to experiment, clear feature boundaries, enables parallel work, good for code review.
- Cons: Merge conflicts can be complex if branches are long-lived, requires discipline to keep branches updated.
Code Review for Changelogs
- Pros: Catches errors early, ensures best practices, shares knowledge, improves security and performance, fosters collaboration.
- Cons: Can be a bottleneck if not integrated efficiently, requires expertise from reviewers.
Proactive Conflict Avoidance
- Pros: Reduces rework, prevents checksum errors, ensures smooth CI/CD.
- Cons: Requires upfront planning and adherence to conventions.
Fix-Forward Strategy
- Pros: Maintains linear history, less risky than rollbacks, easier to audit.
- Cons: Can lead to “messy” changelog with many corrective entries.
Common pitfalls and how to avoid them
- Long-Lived Feature Branches: Lead to massive merge conflicts, especially in changelogs. Merge
main/developfrequently into feature branches. - Ignoring Checksum Errors: In production, never bypass checksum errors with
changelog-sync-force. Always investigate and fix-forward or revert the problematic change in source control. - Lack of Changelog Review: Treating changelogs as just “database scripts” without proper code review is a recipe for disaster.
- Unclear Naming Conventions: Contributes to ID collisions and confusion. Document and enforce consistent naming.
- Big Bang Database Changes: Trying to deploy huge, complex schema changes in one go is high-risk. Break them down into smaller, reversible steps.
Troubleshooting and verification steps
- Git Merge Conflicts in Changelogs:
- Use
git statusto identify conflicting files. - Manually edit the conflicted changelog file(s) to combine changes. For
master.xmlwithincludestatements, ensure the order is logical. For individual changesets, ensure uniqueid,author,logicalFilePath. - Run
liquibase validateon the merged changelog to check for syntax errors or Liquibase-specific issues.
- Use
- Checksum Conflict after Merge:
- Occurs if
id::author::logicalFilePathor content of an already applied changeset was modified during the merge. - Resolution: Revert the local change, or (in dev only) use
changelog-sync-force. In production, a fix-forward strategy is necessary.
- Occurs if
- “Missing Table/Column” on Dev but works on Local:
- Often indicates that a changelog change was pushed but the dev database wasn’t updated, or the CI/CD job’s database URL is wrong.
- Run
liquibase status --verboseon the dev environment’s database to see what’s pending.
Takeaway checklist
- Adopted a branching strategy suitable for collaborative database development.
- Established a code review process for Liquibase changelogs.
- Implemented naming conventions and
logicalFilePathstrategies to avoid ID conflicts. - Understands how to safely resolve Git merge conflicts in changelogs.
- Knows the correct (fix-forward) approach for resolving checksum conflicts in production.
- Can outline strategies for breaking down and managing large, risky database changes.
14) Advanced Topics: Flows (Pro), multi-DB strategies, tenant-aware changes, blue/green, canary DB migrations
What you’ll learn
- Understanding Liquibase Pro’s Flows feature for orchestrating complex deployments.
- Strategies for managing database changes across multiple database types (PostgreSQL, MySQL, Oracle, SQL Server).
- Implementing tenant-aware database changes for multi-tenant applications.
- Advanced deployment patterns: Blue/Green and Canary database migrations.
Why it matters
These are expert-level concepts for highly complex, mission-critical, or large-scale database environments. Flows streamline intricate deployment processes, multi-DB strategies ensure broad compatibility, tenant-aware changes are crucial for SaaS, and Blue/Green/Canary patterns minimize downtime and risk during major schema evolutions. Mastering these elevates you to a senior practitioner capable of architecting robust database delivery systems.
Concepts explained simply
These concepts tackle complex deployments by adding layers of intelligence, automation, and safety.
- Flows (Liquibase Pro): A powerful orchestration engine to define complex deployment pipelines within Liquibase. Instead of just “update everything,” Flows allow you to:
- Define sequential steps: run pre-checks, deploy DDL, run data migrations, perform post-deployment tasks.
- Add conditionals: only run a step if a certain condition is met (e.g., environment is prod, previous step succeeded).
- Integrate external actions: call shell scripts, webhooks, or other tools.
- Implement manual approvals: pause a flow for human review at critical junctures.
- Why: Great for highly regulated environments, complex multi-component deployments, or combining Liquibase runs with other operational tasks.
- Multi-DB Strategies: Deploying the same logical changelog to different database platforms (e.g., PostgreSQL in dev, Oracle in prod).
- Abstraction: Liquibase’s XML/YAML/JSON change types offer a good level of abstraction, generating appropriate SQL for each DB.
dbmsattribute: On a<changeSet>or<change>tag, you can specifydbms="postgresql, oracle". This makes the changeset/change only run on the specified DBMS. Useful for database-specific optimizations or workarounds.- Database-Specific SQL: For highly specific DDL/DML, use separate
.sqlfiles with thedbmsattribute on thesqlFileorsqltag.
- Tenant-Aware Changes: In a multi-tenant application where each tenant has its own schema or dedicated database, you need to apply changes “per tenant.”
- Contexts/Labels: Use contexts or labels to target specific tenants or groups of tenants (e.g.,
--contexts=tenant_A). - Parameter Substitution: Pass tenant IDs or schema names as parameters (
${tenantSchema}) to apply changes within the correct scope. - Iterative Scripting: Often involves a wrapper script that iterates through a list of tenants, calling Liquibase for each.
- Contexts/Labels: Use contexts or labels to target specific tenants or groups of tenants (e.g.,
- Blue/Green DB Migrations: A deployment strategy to minimize downtime during database schema changes.
- Concept: Maintain two identical production environments, “Blue” (current active) and “Green” (new version).
- Process:
- Deploy new database schema changes to the “Green” database while “Blue” is still serving traffic.
- Test “Green” thoroughly.
- When ready, switch traffic from “Blue” to “Green” (e.g., by changing a load balancer or DNS pointer).
- “Blue” becomes the old version, kept as a rollback option.
- Liquibase Role: Liquibase performs the schema update on the “Green” database.
- Canary DB Migrations: Gradually roll out database changes to a small subset of users/servers before a full deployment.
- Concept: Similar to Blue/Green, but traffic is slowly shifted.
- Process:
- Deploy new schema to a small “Canary” database instance (or a replica).
- Direct a tiny percentage of traffic to the Canary.
- Monitor the Canary database and application for issues.
- If stable, gradually increase traffic or proceed with a full Blue/Green switch.
- Liquibase Role: Liquibase updates the “Canary” database.
UI navigation (if any) with “what is where” and step-by-step
Liquibase Hub is where Liquibase Pro Flows are configured and monitored.
- Defining and Executing Flows (Liquibase Hub):
- Log in to Liquibase Hub.
- Navigate to your Project.
- Look for the “Flows” or “Workflows” section.
- Here, you can:
- Create a New Flow: Define a series of steps (Liquibase commands, custom scripts, approvals).
- Visual Editor: Flows often have a visual drag-and-drop editor to define sequences, parallel steps, and conditionals.
- Step Types:
LiquibaseUpdate,ManualApproval,RunScript,WebhookCall,PolicyCheck. - Execution: You can trigger flows manually from Hub, or integrate them with your CI/CD pipeline (e.g., using a CLI command like
liquibase flow run <flow_id>).
- Monitoring Flow Runs: The “Flows” section also shows the status of ongoing and historical flow executions, including step-by-step logs, durations, and any pauses for approval.
- “What is where”: Flows provide a higher-level orchestration layer than just individual Liquibase commands, managed and visualized within Hub.
Step-by-step lab(s) with copy-pasteable commands and expected output
Lab: Multi-DB Strategy
Setup:
my-multi-db-project/liquibase.properties (for H2).
You will need a PostgreSQL and a MySQL database running (e.g., via Docker) to fully test.
- PostgreSQL:
docker run --name some-postgres -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 -d postgres:latest - MySQL:
docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=mysecretpassword -e MYSQL_DATABASE=testdb -p 3306:3306 -d mysql:8.0You will need Liquibase images with drivers for each (from Section 11).
my-multi-db-project/master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="cross_db">
<comment>Create shared settings table</comment>
<createTable tableName="app_settings">
<column name="setting_key" type="varchar(100)"> <constraints primaryKey="true"/> </column>
<column name="setting_value" type="text"/>
</createTable>
</changeSet>
<changeSet id="2" author="cross_db" dbms="postgresql">
<comment>PostgreSQL-specific: Create a composite type</comment>
<sql>
CREATE TYPE address_type AS (
street VARCHAR(255),
city VARCHAR(100),
zipcode VARCHAR(10)
);
</sql>
</changeSet>
<changeSet id="3" author="cross_db" dbms="mysql">
<comment>MySQL-specific: Create an event table with AUTO_INCREMENT</comment>
<createTable tableName="event_log">
<column name="id" type="int" autoIncrement="true">
<constraints primaryKey="true"/>
</column>
<column name="message" type="varchar(1024)"/>
<column name="created_at" type="timestamp" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
</changeSet>
<changeSet id="4" author="cross_db">
<comment>Insert initial settings data</comment>
<insert tableName="app_settings">
<column name="setting_key" value="VERSION"/>
<column name="setting_value" value="1.0"/>
</insert>
</changeSet>
</databaseChangeLog>
Run
updateagainst PostgreSQL: Use your custommy-liquibase-postgres:4.33.0image.docker run --rm -v $(pwd):/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:postgresql://host.docker.internal:5432/postgres" \ -e LIQUIBASE_COMMAND_USERNAME="postgres" \ -e LIQUIBASE_COMMAND_PASSWORD="mysecretpassword" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \ -e LIQUIBASE_COMMAND_DRIVER="org.postgresql.Driver" \ my-liquibase-postgres:4.33.0 \ updateExpected Output: Changesets 1, 2, 4 should execute. ChangeSet 3 (
dbms="mysql") should be skipped.Run
updateagainst MySQL: Use your custommy-liquibase-mysql:4.33.0image.docker run --rm -v $(pwd):/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="jdbc:mysql://host.docker.internal:3306/testdb" \ -e LIQUIBASE_COMMAND_USERNAME="root" \ -e LIQUIBASE_COMMAND_PASSWORD="mysecretpassword" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \ -e LIQUIBASE_COMMAND_DRIVER="com.mysql.cj.jdbc.Driver" \ my-liquibase-mysql:4.33.0 \ updateExpected Output: Changesets 1, 3, 4 should execute. ChangeSet 2 (
dbms="postgresql") should be skipped.Clean up database containers:
docker stop some-postgres some-mysql docker rm some-postgres some-mysql
Lab: Tenant-Aware Changes (Conceptual)
Setup: Imagine a multi-tenant application where each tenant has a separate schema (e.g., tenant_A, tenant_B) within the same PostgreSQL database.
tenant-aware-changelog.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog ...> <changeSet id="1" author="multi_tenant_admin"> <comment>Add tenant-specific user preferences table</comment> <createTable schemaName="${tenantSchemaName}" tableName="user_preferences"> <column name="user_id" type="int"> <constraints primaryKey="true"/> </column> <column name="theme" type="varchar(50)" defaultValue="light"/> </createTable> </changeSet> </databaseChangeLog>Wrapper Script to Apply per Tenant:
#!/bin/bash TENANTS=("tenant_A" "tenant_B" "tenant_C") DB_URL="jdbc:postgresql://host.docker.internal:5432/multitenantdb" DB_USERNAME="admin" DB_PASSWORD="admin_password" for TENANT in "${TENANTS[@]}"; do echo "Applying changes for tenant: $TENANT" docker run --rm -v $(pwd):/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="${DB_URL}" \ -e LIQUIBASE_COMMAND_USERNAME="${DB_USERNAME}" \ -e LIQUIBASE_COMMAND_PASSWORD="${DB_PASSWORD}" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \ -e LIQUIBASE_COMMAND_DRIVER="org.postgresql.Driver" \ -DtenantSchemaName="${TENANT}" \ my-liquibase-postgres:4.33.0 \ update \ --changeLogFile="tenant-aware-changelog.xml" # Or your master.xml if it includes this. echo "-------------------------------------" doneThis script iterates through tenants, passing
tenantSchemaNameas a Liquibase property, causing thecreateTableto be executed in the context of each tenant’s schema.
Lab: Blue/Green & Canary Migrations (Conceptual)
These are architectural patterns that Liquibase facilitates, rather than direct Liquibase commands.
Blue/Green Deployment Flow (Conceptual):
- Phase 1 (Preparation):
- Provision a new “Green” database instance, identical to “Blue” (current production).
- Liquibase step:
liquibase --url=<green_db_url> update(applies all new schema changes to Green). - CI/CD step: Deploy the new application version to “Green” application servers, configured to connect to “Green” DB.
- Phase 2 (Testing):
- Run full integration and regression tests against the “Green” environment.
- Liquibase step: Run
liquibase diff --url=<green_db_url> --reference-url=<expected_schema_snapshot>for final drift check.
- Phase 3 (Switchover):
- CI/CD step: Update load balancer, DNS, or service mesh to redirect 100% of traffic from “Blue” to “Green” application servers.
- “Blue” environment is kept warm as a quick rollback target.
- Phase 4 (Decommission/Rollback):
- If Green is stable, decommission Blue (or keep it as the next Green for the following deployment cycle).
- If Green has issues, rapidly switch traffic back to Blue.
- Phase 1 (Preparation):
Canary Deployment Flow (Conceptual):
- Phase 1 (Canary Setup):
- Provision a small “Canary” database instance (often a read replica or a dedicated small instance) with the new schema changes.
- Liquibase step:
liquibase --url=<canary_db_url> update - CI/CD step: Deploy new application version to a small set of “Canary” application servers, pointing to “Canary” DB.
- Phase 2 (Traffic Shifting & Monitoring):
- CI/CD step: Gradually shift a small percentage (e.g., 5%) of production traffic to the “Canary” app/DB.
- Monitoring step: Closely observe performance, errors, and application metrics for the Canary.
- Phase 3 (Rollout / Rollback):
- If Canary is stable: Continue gradually shifting traffic to the new version until it reaches 100% (effectively becoming a Blue/Green).
- If Canary shows issues: Immediately revert traffic from Canary back to the old version.
- Phase 1 (Canary Setup):
Exercises (5+ per major section)
Multi-DB - Oracle Specific Change:
- Scenario: You need to add a
CLOBcolumn for large text in anapp_datatable, but Oracle handlesCLOBdifferently than other databases. - Task: Write a Liquibase changeset (XML format) that:
- Creates an
app_datatable with anidandtitle(VARCHAR(255)). This change should run onpostgresql, mysql, oracle. - Adds a
large_text_contentcolumn (typeCLOB) toapp_dataonly for Oracle. - Adds a
large_text_contentcolumn (typetext) toapp_datafor PostgreSQL and MySQL.
- Creates an
- Hint: Use the
dbmsattribute on theaddColumntags.
- Scenario: You need to add a
Flows (Pro) - Gated Deployment Orchestration (Conceptual):
- Scenario: You have a
master.xmlchangelog. Your deployment to production requires a sequence:- Validate changelog with policy checks (fail on critical issues).
- Generate
updateSQLand upload as an artifact. - Manual Approval by a DBA.
- Run
updatecommand. - Run a custom
post-deploy-script.shto refresh materialized views. - Send a success notification via webhook.
- Task: Outline the structure of a Liquibase Pro Flow that orchestrates these steps. Describe which Flow step types you would use for each.
- Hint: Focus on the
Step Type(e.g.,LiquibaseUpdate,ManualApproval) and the sequence.
- Scenario: You have a
Tenant-Aware - Adding a Common Column with Parameter Substitution:
- Scenario: In a multi-tenant PostgreSQL setup (schema per tenant), you need to add a
last_modified_bycolumn to alluser_profilestables, regardless of the tenant. - Task:
- Write a Liquibase changeset (XML/YAML) that adds
last_modified_by(VARCHAR(100)) to theuser_profilestable, but uses parameter substitution forschemaName. - Outline a bash script that iterates through a list of tenant schema names and calls Liquibase to apply this changeset to each.
- Write a Liquibase changeset (XML/YAML) that adds
- Hint: Refer to the
tenantSchemaNameexample in the lab.
- Scenario: In a multi-tenant PostgreSQL setup (schema per tenant), you need to add a
Blue/Green - Data Transformation (Conceptual):
- Scenario: You have a Blue/Green deployment model. A major schema change involves splitting an existing
addresscolumn (VARCHAR) intostreet,city,zipcode(all VARCHAR) in thecustomerstable. - Task: Describe how Liquibase would be used to apply this transformation to the “Green” database, ensuring the application remains compatible with the “Blue” database during the transition.
- Hint: Think about adding new columns, writing a data migration to populate them, then modifying the application code and eventually dropping the old column. This is often a multi-phase migration within Green.
- Scenario: You have a Blue/Green deployment model. A major schema change involves splitting an existing
Canary Migrations - Performance Monitoring (Conceptual):
- Scenario: A new index is being introduced on a critical table. You want to canary release this change to monitor performance before full rollout.
- Task:
- Describe how Liquibase would deploy the index to a “Canary” database.
- What kind of monitoring would you put in place to decide if the canary is successful or needs to be rolled back?
- Hint: Focus on deploying to a subset and then observing key metrics like query latency, CPU, and error rates.
Solutions (Hints)
Multi-DB - Oracle Specific Change:
<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog ...> <changeSet id="1" author="cross_db_oracle" dbms="postgresql, mysql, oracle"> <comment>Create app_data table for all DBs</comment> <createTable tableName="app_data"> <column name="id" type="int"> <constraints primaryKey="true"/> </column> <column name="title" type="varchar(255)"/> </createTable> </changeSet> <changeSet id="2" author="cross_db_oracle" dbms="oracle"> <comment>Oracle-specific: Add CLOB column</comment> <addColumn tableName="app_data"> <column name="large_text_content" type="CLOB"/> </addColumn> </changeSet> <changeSet id="3" author="cross_db_oracle" dbms="postgresql, mysql"> <comment>Postgres/MySQL-specific: Add TEXT column</comment> <addColumn tableName="app_data"> <column name="large_text_content" type="text"/> </addColumn> </changeSet> </databaseChangeLog>Flows (Pro) - Gated Deployment Orchestration (Conceptual):
Flow Name: Prod_Deployment_Flow Description: Orchestrates production database migrations with approval gate. Steps: 1. Step Type: LiquibaseValidate * Command: `validate` * Configuration: Fail on severe policy violations. * On Fail: Halt Flow * (Implicitly uses LIQUIBASE_PRO_LICENSE_KEY and other config) 2. Step Type: LiquibaseUpdateSQL * Command: `updateSQL` * Arguments: `--contexts=prod --output-file=prod_migration.sql` * Post-Action: Upload `prod_migration.sql` as CI/CD artifact. 3. Step Type: ManualApproval * Message: "Review SQL script and approve deployment to Production." * Approvers: DBA Team Lead (e.g., specific Hub user group) * On Reject: Halt Flow 4. Step Type: LiquibaseUpdate * Command: `update` * Arguments: `--contexts=prod` * On Fail: Halt Flow (with configured rollback on error if desired) 5. Step Type: RunScript * Script Path: `/app/scripts/refresh_materialized_views.sh` (external script) * Arguments: `prod_env_id` (passed to script) * On Fail: Warn and Continue (or Halt if critical) 6. Step Type: WebhookCall * URL: `https://your-slack-webhook.com/alerts` * Method: POST * Body: `{ "status": "Production Deployment Successful", "flowId": "${FLOW_ID}" }` * On Fail: Ignore (best-effort notification)Tenant-Aware - Adding a Common Column with Parameter Substitution:
db/common_tenant_changes.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog ...> <changeSet id="1" author="tenant_admin"> <comment>Add last_modified_by column to user_profiles table for tenant ${tenantSchemaName}</comment> <addColumn schemaName="${tenantSchemaName}" tableName="user_profiles"> <column name="last_modified_by" type="varchar(100)"/> </addColumn> </changeSet> </databaseChangeLog>- Bash script (similar to lab, updated for this changelog):
#!/bin/bash TENANTS=("tenant_A" "tenant_B" "tenant_C") DB_URL="jdbc:postgresql://host.docker.internal:5432/multitenantdb" DB_USERNAME="admin" DB_PASSWORD="admin_password" LIQUIBASE_IMAGE="my-liquibase-postgres:4.33.0" # Assuming custom image with Postgres driver for TENANT in "${TENANTS[@]}"; do echo "Applying changes for tenant: $TENANT" docker run --rm -v $(pwd):/liquibase/changelog \ -e LIQUIBASE_COMMAND_URL="${DB_URL}" \ -e LIQUIBASE_COMMAND_USERNAME="${DB_USERNAME}" \ -e LIQUIBASE_COMMAND_PASSWORD="${DB_PASSWORD}" \ -e LIQUIBASE_COMMAND_CHANGELOG_FILE="db/common_tenant_changes.xml" \ -e LIQUIBASE_COMMAND_DRIVER="org.postgresql.Driver" \ -DtenantSchemaName="${TENANT}" \ "${LIQUIBASE_IMAGE}" \ update if [ $? -ne 0 ]; then echo "Error applying Liquibase for tenant ${TENANT}. Halting." exit 1 fi echo "-------------------------------------" done echo "All tenant migrations completed."
Blue/Green - Data Transformation (Conceptual):
- Phase 1 (Green DB Update - Non-disruptive):
- Liquibase update on the “Green” DB:
- Changeset A: Add
street_new,city_new,zipcode_newcolumns (all nullable) tocustomers. - Changeset B: Data migration script to parse and populate
street_new,city_new,zipcode_newfrom the existingaddresscolumn. - Changeset C: Add
last_migration_statuscolumn (VARCHAR) tocustomersfor tracking data migration state.
- Changeset A: Add
- Result: Green DB has both old (
address) and new (street_new, etc.) columns. Applications connecting to Green can still useaddress.
- Liquibase update on the “Green” DB:
- Phase 2 (Application Deployment to Green):
- Deploy new application code to “Green” app servers. This app code should be dual-read/dual-write: it can read from
addressorstreet_new(prioritizing new), and write to bothaddressandstreet_new(or a migration path).
- Deploy new application code to “Green” app servers. This app code should be dual-read/dual-write: it can read from
- Phase 3 (Switch Traffic):
- Traffic shifts to “Green” environment.
- Phase 4 (Post-Switch Cleanup/Validation):
- Once Green is stable and traffic fully shifted, another Liquibase update on Green (now the primary):
- Changeset D: Drop the old
addresscolumn. - Changeset E: Rename
street_newtostreet,city_newtocity, etc. (using<renameColumn>). - Changeset F: Make new columns non-nullable.
- Changeset D: Drop the old
- This phased approach ensures application compatibility during the entire switchover.
- Once Green is stable and traffic fully shifted, another Liquibase update on Green (now the primary):
- Phase 1 (Green DB Update - Non-disruptive):
Canary Migrations - Performance Monitoring (Conceptual):
- Liquibase Deployment:
- Liquibase update to a small, isolated “Canary” database instance (e.g., a replica of production, or a dedicated small environment). The changelog would contain the new index (e.g.,
createIndex). liquibase --url=<canary_db_url> update --contexts=canary
- Liquibase update to a small, isolated “Canary” database instance (e.g., a replica of production, or a dedicated small environment). The changelog would contain the new index (e.g.,
- Monitoring:
- Direct a small percentage (e.g., 1-5%) of production traffic to application servers connected to this Canary DB.
- Key Metrics to Monitor:
- Database: Query latency (for queries hitting the indexed table), CPU usage, I/O, lock contention, buffer pool hit ratios.
- Application: Transaction response times, error rates, throughput.
- Specific Queries: Identify critical queries that would benefit (or be negatively impacted) by the new index and monitor their performance directly.
- Decision: If the canary performs well (no regressions, ideal improvements), proceed with full rollout. If performance degrades, revert the canary application, and roll back the index on the canary database.
- Liquibase Deployment:
Pros and cons
Flows (Liquibase Pro)
- Pros: Centralized orchestration of complex deployments, combines Liquibase commands with external scripts/approvals, visual editor, enhances governance and compliance.
- Cons: Requires Liquibase Pro, adds a layer of abstraction, potentially steeper learning curve.
Multi-DB Strategies
- Pros: Enables a single codebase/changelog to target multiple database platforms, promotes consistency, reduces maintenance for polyglot persistence.
- Cons: Requires careful testing on each DB type, database-specific SQL still needs managing (via
dbmsor separate files), abstraction might not cover all edge cases.
Tenant-Aware Changes
- Pros: Essential for multi-tenant SaaS architectures, ensures changes are applied correctly to each tenant’s isolated environment, automates repetitive tasks.
- Cons: Adds complexity to changelogs (parameterization, contexts), requires robust wrapper scripting/orchestration, potential for tenant-specific errors.
Blue/Green DB Migrations
- Pros: Near-zero downtime deployments, provides immediate rollback option, reduced risk for major schema changes.
- Cons: High infrastructure cost (running two production environments), requires careful data synchronization strategy if data is highly transactional, application must be compatible with both old and new schemas during transition.
Canary DB Migrations
- Pros: Gradually exposes changes, early detection of issues with minimal user impact, allows real-world performance validation.
- Cons: More complex to set up than Blue/Green, requires sophisticated traffic routing and monitoring, can be challenging for stateful services.
Common pitfalls and how to avoid them
- Incomplete
dbmsDefinitions: Forgetting to adddbmsto a changeset (or adding an incorrect value) in a multi-DB scenario can lead to errors or unintended deployments on the wrong DB. - Assuming Cross-DB Compatibility: While Liquibase abstracts, not all change types behave identically across DBs. Test thoroughly on all target platforms.
- No Rollback for Multi-DB
dbmsChanges: Ensure yourrollbackblocks also respect thedbmsattribute or provide generic rollbacks. - Data Consistency in Blue/Green/Canary: For highly transactional systems, ensure a strategy for data replication/synchronization between Blue/Green or from Prod to Canary to avoid data loss or inconsistency during cutovers. This often involves logical replication or dual writes from the application.
- Hardcoding Tenant Details: Never embed actual tenant IDs or sensitive tenant info in changelogs. Use parameter substitution and environment-specific lists of tenants.
- Lack of Monitoring in Canary: A canary release without robust, real-time monitoring is pointless. You need quick feedback to decide on promotion or rollback.
Troubleshooting and verification steps
- Changeset not running on a specific DB type:
- Check the
dbmsattribute on the changeset. Is the target database listed? - Verify the driver and connection URL are correctly identifying the database type (e.g., PostgreSQL driver for PostgreSQL).
- Increase
logLeveltoDEBUGto see which changesets are being evaluated and skipped due todbmsfilters.
- Check the
- Tenant-aware change failing for specific tenants:
- Verify the
tenantSchemaName(or equivalent) parameter is correctly substituted for that tenant. - Check database permissions for the
liquibase_useron that specific tenant’s schema. - Manually run the generated SQL for that tenant to debug.
- Verify the
- Blue/Green / Canary rollout issues:
- Application errors: Indicates new schema isn’t compatible with application, or data migration failed.
- Performance degradation: New schema changes (indexes, constraints) might be performing worse than expected. Rollback and re-evaluate.
- Data loss/corruption: Indicates issues in data migration or application’s dual-write logic. Critical: Rollback immediately.
Takeaway checklist
- Understands Liquibase Pro’s Flows for complex deployment orchestration.
- Can implement changelogs that target multiple database types using the
dbmsattribute. - Can structure tenant-aware changes using parameter substitution and wrapper scripts.
- Understands the principles and Liquibase’s role in Blue/Green and Canary database migrations.
- Aware of the critical importance of data consistency and application compatibility in advanced deployment patterns.
15) Troubleshooting Playbook: connectivity, driver issues, checksum conflicts, lock handling, noisy diffs
What you’ll learn
- A systematic approach to diagnosing common Liquibase errors.
- Troubleshooting database connectivity problems.
- Resolving JDBC driver-related issues.
- Strategies for handling checksum validation failures.
- Dealing with
DATABASECHANGELOGLOCKissues. - Techniques for managing noisy
diffoutput.
Why it matters
Even with the best practices, errors happen. A robust troubleshooting playbook is essential for minimizing downtime, quickly identifying root causes, and ensuring reliable database deployments. Knowing how to systematically approach errors, interpret messages, and apply the right Liquibase commands for remediation is a hallmark of an expert practitioner.
Concepts explained simply
Troubleshooting is about detective work. You look for clues (error messages, logs, database state) and apply known solutions.
- Systematic Approach: Don’t panic. Read the error message fully. Check logs. Replicate locally. Isolate the problem.
- Connectivity Issues: Liquibase can’t talk to the database. This could be network, firewall, hostname, port, or credential problems.
- Driver Issues: Liquibase can connect, but can’t find the right “translator” (JDBC driver) for your specific database type.
- Checksum Conflicts: An already-applied changeset’s content has changed, or its identifying attributes (
id,author,logicalFilePath) no longer match theDATABASECHANGELOG. This is Liquibase’s primary safety mechanism against unintentional changes to history. DATABASECHANGELOGLOCKHandling: Prevents multiple Liquibase instances from running concurrently. If a previous run crashes, the lock might not be released.- Noisy Diffs:
liquibase diffreporting unwanted or irrelevant differences, making it hard to find actual drift.
Step-by-step lab(s) with copy-pasteable commands and expected output
Setup: Start with a my-troubleshooting-project using H2 in-memory.
my-troubleshooting-project/liquibase.properties:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
driver: org.h2.Driver
username: sa
password:
changelog-file: master.xml
logLevel: INFO
my-troubleshooting-project/master.xml:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="troubleshooter">
<comment>Create initial users table</comment>
<createTable tableName="users">
<column name="id" type="int"> <constraints primaryKey="true" nullable="false"/> </column>
<column name="username" type="varchar(100)"/>
</createTable>
</changeSet>
<changeSet id="2" author="troubleshooter">
<comment>Add email column</comment>
<addColumn tableName="users">
<column name="email" type="varchar(255)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
Lab: Diagnosing Common Errors
Error Scenario 1: Connectivity Failure
- Simulate: Change
urlinliquibase.propertiesto an incorrect port:url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;AUTO_SERVER=TRUE;PORT=9999(H2 embedded mode doesn’t usually use a port like this, but this will break it for demonstration). Or, if using a real DB, use a wrong hostname/port. - Run
liquibase update. - Expected Error:
Unexpected error running Liquibase: liquibase.exception.DatabaseException: Connection refused: ...(or similar network error depending on DB). - Troubleshooting Steps:
- Check
url: Is hostname, port, database name correct? - Network: Can Liquibase host reach database host (ping, telnet)?
- Firewall: Is port open on DB server?
- Credentials: Are
username,passwordcorrect?
- Check
- Simulate: Change
Error Scenario 2: Driver Not Found
- Simulate: Change
driverinliquibase.propertiesto a non-existent one:driver: com.example.NonExistentDriver. - Run
liquibase update. - Expected Error:
Unexpected error running Liquibase: liquibase.exception.DatabaseException: Driver class not found: com.example.NonExistentDriver - Troubleshooting Steps:
- Driver Name: Is the driver class name correct for your DB?
- Classpath: Is the JDBC driver JAR file in Liquibase’s
libdirectory, or on theLIQUIBASE_CLASSPATH? - Docker: If in Docker, is the driver bundled or explicitly added to your custom image?
- Simulate: Change
Error Scenario 3: Checksum Conflict
- Simulate: Run
liquibase updateonce (both changesets applied). - Now, modify ChangeSet 1 in
master.xml(e.g., changeusernamecolumn type tovarchar(50)). - Run
liquibase update. - Expected Error:
Liquibase command failed with exception: Validation Error: The file master.xml::1::troubleshooter has changed since it was run... Expected checksum was: X:..., but actual is: Y:... - Troubleshooting Steps:
- Identify Changeset: Error message tells you
id::author::logicalFilePath. - Review Change: What was changed? Was it intentional?
- Remediation (Dev): If it’s a dev database and you’re confident the new content is desired, use
liquibase --defaults-file=liquibase.properties changelog-sync-force master.xml::1::troubleshooter. - Remediation (Prod): FIX-FORWARD. Revert the change in source control, then create a new changeset to make the desired schema modification.
- Identify Changeset: Error message tells you
- Simulate: Run
Error Scenario 4:
DATABASECHANGELOGLOCKStuck- Simulate:
- Open two terminal windows, both in
my-troubleshooting-project. - In Terminal 1: Start a long-running, non-completing Liquibase operation (e.g.,
liquibase --defaults-file=liquibase.properties updateSQL > /dev/null & sleep 1000- a background process that holds the lock, or simulate a crash). Or simplyliquibase --defaults-file=liquibase.properties updateand kill the process mid-way (Ctrl+C). - In Terminal 2: Run
liquibase update.
- Open two terminal windows, both in
- Expected Error (Terminal 2):
Liquibase command failed with exception: liquibase.exception.LockException: Could not acquire change log lock. Currently locked by ... - Troubleshooting Steps:
- Identify Locker: Message shows who locked it (hostname, IP, date/time).
- Verify: Is a legitimate Liquibase process still running? (e.g., in a CI/CD job, another terminal).
- If Safe to Release: If no other Liquibase process is running, manually release the lock.Caution: Only release if you are absolutely sure no other Liquibase is active. Releasing prematurely can lead to race conditions and database corruption.
liquibase --defaults-file=liquibase.properties release-locks - Manual Check (if
release-locksfails): QuerySELECT * FROM DATABASECHANGELOGLOCK;and ifLOCKED=TRUE, manuallyUPDATE DATABASECHANGELOGLOCK SET LOCKED=FALSE, LOCKGRANTED=NULL, LOCKEDBY=NULL WHERE ID=1;(Use extreme caution!).
- Simulate:
Error Scenario 5: Noisy
diffOutput- Simulate: Add a temporary test table manually to your H2 DB (e.g.,
liquibase sql --sql="CREATE TABLE temp_junk (id INT PRIMARY KEY);") after Liquibase update. - Run
liquibase diff. - Expected Output:
Unexpected Table: PUBLIC.TEMP_JUNK(along with Liquibase’s own tables if not excluded). - Troubleshooting Steps:
- Identify Unwanted Objects: Determine which objects are consistently reported but irrelevant.
- Exclude in
liquibase.properties: AdddiffExcludeObjects: ^TEMP_JUNK$, ^DATABASECHANGELOG$, ^DATABASECHANGELOGLOCK$to yourliquibase.propertiesfile. - Refine Patterns: Use regular expressions carefully to match only the intended objects.
- Pro Features: Liquibase Pro offers more advanced filtering and persistent exclusion rules through Liquibase Hub.
- Simulate: Add a temporary test table manually to your H2 DB (e.g.,
Exercises (10 realistic errors)
For each exercise, describe the likely error message Liquibase would produce and then detail the steps to diagnose and fix it. Assume a PostgreSQL database, a clean H2 (for replication/testing), and a Liquibase Docker setup for consistency.
Missing
driverinliquibase.properties(PostgreSQL):liquibase.properties:url: jdbc:postgresql://localhost:5432/testdb,username: user,password: pass. Nodriverline.- Command:
liquibase update. - Error: ?
- Fix: ?
Incorrect
dbmsattribute:master.xml:<changeSet id="1" author="test" dbms="oracle"> <createTable tableName="test_table">...</createTable> </changeSet>liquibase.propertiesfor PostgreSQL.- Command:
liquibase update. - Error: ?
- Fix: ?
Attempting
rollback-count 1with an un-rollbackabledropTable:master.xml:<changeSet id="1" author="test"> <dropTable tableName="unrecoverable_table"/> </changeSet>(no explicit rollback).- Run
liquibase update(on a fresh DB whereunrecoverable_tableexists). - Run
liquibase rollback-count 1. - Error: ?
- Fix: ?
Duplicate
idwithin the same changelog file:master.xml: Contains two changesets withid="1" author="dev"in the same file.- Command:
liquibase update. - Error: ?
- Fix: ?
logicalFilePathchanged for an already-applied changeset:- You ran
liquibase updatewithdb/initial.xml::1::dev. - Then you moved
db/initial.xmltofeatures/db/first_release.xmland updated yourmaster.xmlto include the new path. - Command:
liquibase update. - Error: ?
- Fix: ?
- You ran
sqlCheckprecondition failsonFail="HALT":master.xml:<changeSet id="1" author="test"> <preConditions onFail="HALT"> <sqlCheck expectedResult="true">SELECT 1=0;</sqlCheck> </preConditions> <createTable tableName="never_created">...</createTable> </changeSet>- Command:
liquibase update. - Error: ?
- Fix: ?
runAlwayschangeset fails on subsequent run (non-idempotent insert):master.xml:<changeSet id="1" author="data_load" runAlways="true"> <insert tableName="my_config"> <column name="id" valueNumeric="1"/> <column name="value" value="initial"/> </insert> </changeSet>my_configtable has a primary key onid.- Run
liquibase updateonce. - Run
liquibase updateagain. - Error: ? (Specific to DB, e.g., unique constraint violation).
- Fix: ?
Docker: Host database (PostgreSQL) unreachable from container:
- PostgreSQL running on
localhost:5432on macOS/Windows host. liquibase.properties:url: jdbc:postgresql://localhost:5432/testdb(wrong for Docker).- Command:
docker run ... liquibase update. - Error: ?
- Fix: ?
- PostgreSQL running on
Pro: Policy check
FAILonvalidate:master.xml: Changeset with no<comment>tag.- A Liquibase Pro policy is configured to
FAILbuilds if changesets lack comments. - Command:
liquibase validate. - Error: ?
- Fix: ?
File permissions issue for changelog:
master.xmlhas insufficient permissions for the user running Liquibase (e.g., owned byroot, no read forother).- Command:
liquibase update. - Error: ?
- Fix: ?
Solutions (Hints)
Missing
driver:- Error:
Unexpected error running Liquibase: liquibase.exception.DatabaseException: No suitable driver found for jdbc:postgresql://localhost:5432/testdb - Fix: Add
driver: org.postgresql.Drivertoliquibase.propertiesand ensure the driver JAR is on the classpath (or in the Docker image).
- Error:
Incorrect
dbmsattribute:- Error: No explicit error. Liquibase will silently skip the changeset because
dbms="oracle"does not match the targetpostgresql.liquibase statuswould still show it as pending if you tried to apply. - Fix: Change
dbms="oracle"todbms="postgresql"(or remove it if intended for all DBs).
- Error: No explicit error. Liquibase will silently skip the changeset because
Attempting
rollback-count 1with un-rollbackabledropTable:- Error:
Liquibase command failed with exception: liquibase.exception.RollbackFailedException: No inverse statement for liquibase.change.core.DropTableChange - Fix: Add an explicit
<rollback>block to the changeset containing thedropTable(e.g.,<rollback><createTable tableName="unrecoverable_table">...</createTable></rollback>).
- Error:
Duplicate
idwithin the same changelog file:- Error:
Liquibase command failed with exception: liquibase.exception.ChangeLogParseException: Found 2 or more changeSets with the same id and author combination - Fix: Ensure all changesets within a single changelog file (or effectively, within the same
logicalFilePath) have uniqueid+authorcombinations. Update theidof one of them.
- Error:
logicalFilePathchanged for an already-applied changeset:- Error:
Liquibase command failed with exception: Validation Error: The file db/initial.xml::1::dev has changed since it was run at ... Expected checksum was: ..., but actual is: ... - Fix:
- Revert the file move.
- Add
logicalFilePath="db/initial.xml"to thechangeSettag ininitial.xml. - Then perform the file move and update
master.xml. The explicitlogicalFilePathensures Liquibase finds the change correctly. - (Dev only): If content hasn’t changed,
changelog-sync-force db/initial.xml::1::devcan update the checksum if thelogicalFilePathis stable.
- Error:
sqlCheckprecondition failsonFail="HALT":- Error:
Liquibase command failed with exception: liquibase.exception.PreconditionFailedException: Precondition failed: ...: Custom SQL Check Failed - Fix: Correct the condition in
sqlCheckso it evaluates totruewhen desired, or changeonFail="HALT"toonFail="CONTINUE"oronFail="MARK_RAN"if the failure is acceptable.
- Error:
runAlwayschangeset fails on subsequent run (non-idempotent insert):- Error:
Liquibase command failed with exception: liquibase.exception.DatabaseException: org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "my_config_pkey"(or similar for MySQL/H2). - Fix: Make the insert statement idempotent. For PostgreSQL, use
ON CONFLICT (id) DO NOTHING. For MySQL, useINSERT IGNORE. For H2, useMERGE INTO my_config (id, value) KEY(id) VALUES (1, 'initial');.
- Error:
Docker: Host database (PostgreSQL) unreachable from container:
- Error:
Unexpected error running Liquibase: liquibase.exception.DatabaseException: org.postgresql.util.PSQLException: Connection to localhost:5432 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections. - Fix: Change
localhostin the JDBC URL tohost.docker.internal(for Docker Desktop on macOS/Windows) or the host machine’s IP address (for Linux). Ensure the host firewall allows connections to the database port.
- Error:
Pro: Policy check
FAILonvalidate:- Error:
Liquibase command failed with exception: Policy check failed: 1 critical violation(s) found. Violation: changeset-must-have-comment ... - Fix: Add a meaningful
<comment>tag to the violating changeset inmaster.xml. Then re-runliquibase validate.
- Error:
File permissions issue for changelog:
- Error:
Unexpected error running Liquibase: java.io.FileNotFoundException: master.xml (Permission denied)(or similar). - Fix: Change file permissions on
master.xml(and included changelogs) to allow the user or process running Liquibase to read them (e.g.,chmod 644 master.xml). If running in Docker, ensure the mounted volume has correct permissions for the user inside the container.
- Error:
Pros and cons
A Structured Troubleshooting Playbook
- Pros: Speeds up issue resolution, reduces stress, builds confidence, ensures consistency in handling errors, enables junior team members to diagnose problems.
- Cons: Requires initial effort to document and maintain, still needs human expertise for complex or novel issues.
Common pitfalls and how to avoid them
- Panic and Random Trial-and-Error: Leads to more problems. Always follow a systematic approach.
- Not Reading Full Error Messages: Critical information (like changeset ID, file path, specific DB error codes) is often in the details.
- Modifying Production
DATABASECHANGELOG: Highly dangerous and should only be done as an absolute last resort, with full database backups, and only by very experienced personnel. It bypasses Liquibase’s integrity checks. - Ignoring
logLevel: DEBUG: When stuck, increasing verbosity provides invaluable context. - Assuming Local Fixes Apply to CI/CD/Prod: Always test fixes in an environment as close to the target as possible. Docker helps ensure consistency.
Troubleshooting and verification steps
- Reproduce the error: The first step in effective troubleshooting is to consistently reproduce the error, ideally in a local, isolated environment (like H2).
- Check
logLevel: SetlogLevel: DEBUGinliquibase.propertiesor on the command line (--log-level=DEBUG) for detailed output. - Examine
DATABASECHANGELOG: Directly query this table to understand what Liquibase believes has been applied. - Check Database State: Use a SQL client to inspect the actual database schema and data. Does it match your expectations?
- Use
updateSQL: For complex issues,updateSQLcan show you exactly what SQL Liquibase is trying to run, which helps in debugging database-specific errors. - Consult Liquibase Documentation: The official Liquibase documentation and community forums are excellent resources for specific error messages or common problems.
Takeaway checklist
- Can systematically diagnose common Liquibase errors (connectivity, driver, checksum, lock).
- Knows how to use
release-lockssafely. - Understands the critical difference between fixing checksum errors in dev vs. prod (fix-forward).
- Can leverage
logLevel: DEBUGandupdateSQLfor effective debugging. - Knows how to filter noisy
diffoutput. - Understands the dangers of manually modifying
DATABASECHANGELOG.
16) Liquibase vs Flyway: quick comparison and selection guidance
What you’ll learn
- A concise overview of the key differences between Liquibase and Flyway.
- Understanding their philosophical approaches to database migrations.
- Guidance on when to choose one tool over the other based on project needs.
Why it matters
Liquibase and Flyway are the two leading tools for database change management. Understanding their core philosophies, strengths, and weaknesses is crucial for making an informed decision when selecting a tool for your project, or for articulating why one might be preferred in a given context.
Concepts explained simply
Both tools solve the problem of versioning your database schema. They just do it with slightly different philosophies.
- Liquibase: (The “declarative with escape hatches” approach)
- Changelog Formats: XML, YAML, JSON (declarative, abstracted change types), and plain SQL.
- Philosophy: Focuses on defining changes in an abstract, database-agnostic way (for XML/YAML/JSON). You describe the desired state transition (e.g., “add a column”), and Liquibase generates the specific SQL for your database. You have an optional
rollbacktag. - Rollbacks: Rich, built-in rollback capabilities, including auto-generated (for simple DDL) and explicit
rollbackblocks. Liquibase Pro offers targeted rollbacks. - Features: Strong support for preconditions, contexts, labels, advanced diffing/drift detection (Pro), policy checks (Pro), complex deployment flows (Pro), and audit reporting.
- Metadata: Stores a comprehensive audit trail in
DATABASECHANGELOG(ID, author, filename, checksum, contexts, labels, deployment ID, execution type). - Versioning: Tracks changesets by their unique identifier (
id::author::logicalFilePath).
- Flyway: (The “SQL first” approach)
- Changelog Formats: Primarily plain SQL scripts (
V1__description.sql,R__description.sql). Java-based migrations also possible. - Philosophy: “SQL is king.” You write direct SQL scripts for each migration. Flyway applies these scripts in order. Encourages a “fix-forward” mindset where errors are corrected with new scripts, not by reverting old ones.
- Rollbacks: Minimal native rollback features. Rollbacks are usually handled by writing new, inverse SQL migration scripts or by restoring from backups.
- Features: Simpler, opinionated, easy to get started with SQL. Good support for baselining. Limited advanced features compared to Liquibase Pro.
- Metadata: Stores minimal metadata in
flyway_schema_history(version, description, script name, checksum, installed by, installed on). - Versioning: Tracks changes by version number embedded in filename (
V1.0.0__script_name.sql).
- Changelog Formats: Primarily plain SQL scripts (
High-level Comparison Table
| Feature | Liquibase | Flyway |
|---|---|---|
| Primary Focus | Declarative, flexible formats, advanced features | SQL-first, simplicity, convention over configuration |
| Changelog Formats | XML, YAML, JSON, SQL | SQL, Java |
| Rollbacks | Rich, auto-gen & explicit, Pro targeted | Minimal native, encourages fix-forward/manual |
| Database Abstraction | High (for XML/YAML/JSON) | Low (direct SQL, DB-specific) |
| Pre/Post Conditions | Yes (Built-in) | No (requires custom logic in SQL) |
| Contexts/Labels | Yes (Built-in for conditional execution) | No (requires custom logic in SQL) |
| Diff/Drift Detection | Yes (OSS diff, Pro continuous drift) | No (requires external tools/manual comparison) |
| Policy Checks | Yes (Pro) | No |
| Deployment Flows | Yes (Pro orchestration) | No |
| Complexity | Can be more complex with advanced features | Simpler, fewer moving parts |
| Learning Curve | Steeper for declarative formats | Flatter for SQL users |
| Commercial Offering | Liquibase Pro (extensive features) | Flyway Teams (some advanced features) |
| UI/Dashboard | Liquibase Hub (Pro, web-based) | Flyway Desktop (standalone GUI) |
Selection Guidance
Choosing between Liquibase and Flyway often comes down to your team’s existing skill set, project complexity, and governance requirements.
Choose Liquibase if:
- You need database-agnostic changes: For applications targeting multiple database platforms (e.g., PostgreSQL in dev, SQL Server in prod, or shipping a product that supports many databases), Liquibase’s XML/YAML/JSON abstraction is a major advantage.
- You require strong rollback capabilities: If rapid and reliable rollbacks (especially targeted ones) are critical for your operational recovery strategy.
- Your team values declarative change definitions: If developers prefer defining schema changes in a structured, abstract format rather than raw SQL.
- You need advanced governance and observability: For highly regulated industries, large enterprises, or projects requiring strict policy enforcement, continuous drift detection, and centralized reporting (Liquibase Pro’s strengths).
- You need conditional deployments: For complex CI/CD pipelines requiring
contexts,labels, orpreconditions. - You have complex deployment orchestration needs: Liquibase Pro’s Flows feature can manage multi-step, multi-tool deployment pipelines.
Choose Flyway if:
- Your team is SQL-first: If your developers and DBAs are most comfortable working directly with SQL scripts and want minimal abstraction.
- Simplicity and convention are paramount: If you prefer a tool that does one thing well (applying sequential SQL migrations) with fewer configuration options.
- Your project targets a single database platform: This reduces the need for database abstraction.
- You embrace a strong “fix-forward” philosophy: Where correcting issues with new scripts is the standard, rather than reverting old ones.
- You prefer versioning via sequential numbering: Flyway’s
V<version>__<name>.sqlnaming convention is straightforward. - You have a dedicated desktop UI need: Flyway Desktop provides a graphical interface for migrations.
Hybrid Approaches: It’s also possible to use both tools, especially in large organizations with diverse teams or legacy systems. For instance, a core application might use Liquibase for its declarative changes and advanced features, while a data warehouse team might use Flyway for its SQL-centric approach.
17) Capstone Project: Enterprise-grade pipeline with rollbacks, checks, drift remediation, and auditability
What you’ll learn
- Synthesize all learned concepts into a practical, real-world scenario.
- Build a complete multi-environment CI/CD pipeline (DEV→STAGE→PROD).
- Implement security best practices for secrets management.
- Integrate Liquibase Pro features: policy checks, update reports, structured logs.
- Practice drift monitoring and remediation.
- Demonstrate controlled rollbacks and auditability.
- Set up a robust repository structure for enterprise use.
Why it matters
This capstone project is your opportunity to apply everything you’ve learned. It demonstrates not just theoretical understanding, but the practical ability to design, implement, and operate a secure, reliable, and observable database change management solution, which is a core skill for any senior database DevOps practitioner.
Concepts explained simply
The Capstone brings together the “best of” Liquibase: CI/CD, secure secrets, environments, policies, and a plan for when things go wrong.
- Scenario: Develop an API for managing a product catalog. Your database needs to evolve with schema changes and reference data. You must support DEV, STAGE, and PROD environments, with strict controls for higher environments.
- Technology Stack:
- Version Control: Git
- CI/CD: GitHub Actions (or GitLab CI, adaptable)
- Database: PostgreSQL (Dockerized)
- Liquibase: OSS with conceptual Pro features for checks, flows, observability.
- Key Requirements:
- Multi-environment Pipeline: Automated deployment to DEV, manual approval for STAGE, strict manual approval for PROD.
- Containerized Liquibase: Use a custom Docker image with PostgreSQL driver.
- Secure Secrets: All database credentials managed via CI/CD secrets.
- Changelog Structure: Modular, organized by feature/domain.
- Policy Checks (Pro): Enforce rules like “all changesets must have comments” and “no
DROP TABLEwithout explicit rollback.” - Update Reports & Structured Logs (Pro): Generate reports and structured logs for every deployment.
- Drift Monitoring: Proactive drift detection on STAGE/PROD.
- Rollback Capability: Demonstrate a controlled rollback.
- Auditability: Ensure clear audit trail in
DATABASECHANGELOGand reports. - Preconditions: Use for safe, idempotent changes.
Deliverables: Repo structure, sample changelogs, CI pipelines, runbook, failure drill
17.1) Repository Structure
.
├── .github/
│ └── workflows/
│ └── db-migration.yml # GitHub Actions pipeline definition
├── docker/
│ └── Dockerfile # Custom Liquibase Docker image
├── db/
│ ├── master.xml # Main changelog, includes others
│ ├── common/
│ │ ├── initial_schema.xml # Baseline tables (users, products)
│ │ └── audit_logs.xml # Generic audit table (runAlways/OnChange)
│ ├── features/
│ │ ├── product_reviews.xml # Feature-specific changes
│ │ └── user_profiles.xml # Feature-specific changes
│ ├── data/
│ │ └── ref_data_categories.xml # Reference data (runAlways)
│ └── migrations/ # One-off complex data migrations
│ └── M20251010_populate_categories.xml
├── liquibase.properties # Liquibase default configuration
├── scripts/
│ ├── post_deploy_hook.sh # Example post-deployment script
│ └── db_audit_report.sh # Example script to query DATABASECHANGELOG
├── README.md
└── .gitignore
17.2) Sample Changelogs
db/master.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd"> <property name="schemaName" value="public" /> <!-- Default schema name --> <includeAll path="db/common" relativeToChangelogFile="true"/> <includeAll path="db/features" relativeToChangelogFile="true"/> <includeAll path="db/data" relativeToChangelogFile="true"/> <includeAll path="db/migrations" relativeToChangelogFile="true"/> </databaseChangeLog>db/common/initial_schema.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog ...> <changeSet id="1" author="system"> <comment>Create users table</comment> <createTable tableName="${schemaName}.users"> <column name="id" type="SERIAL"> <constraints primaryKey="true" nullable="false"/> </column> <column name="username" type="varchar(100)"> <constraints nullable="false" unique="true"/> </column> <column name="created_at" type="timestamp" defaultValueComputed="NOW()"> <constraints nullable="false"/> </column> </createTable> <rollback><dropTable tableName="${schemaName}.users"/></rollback> </changeSet> <changeSet id="2" author="system"> <comment>Create products table</comment> <createTable tableName="${schemaName}.products"> <column name="id" type="SERIAL"> <constraints primaryKey="true" nullable="false"/> </column> <column name="name" type="varchar(255)"> <constraints nullable="false"/> </column> <column name="price" type="numeric(10,2)"> <constraints nullable="false"/> </column> </createTable> <rollback><dropTable tableName="${schemaName}.products"/></rollback> </changeSet> </databaseChangeLog>db/features/product_reviews.xml:<?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog ...> <changeSet id="1" author="feature_reviews"> <comment>Add product_reviews table</comment> <createTable tableName="${schemaName}.product_reviews"> <column name="id" type="SERIAL"> <constraints primaryKey="true"/> </column> <column name="product_id" type="int"> <constraints nullable="false"/> </column> <column name="user_id" type="int"> <constraints nullable="false"/> </column> <column name="rating" type="int"> <constraints nullable="false"/> </column> <column name="comment" type="text"/> </createTable> <addForeignKeyConstraint baseTableName="${schemaName}.product_reviews" baseColumnNames="product_id" constraintName="fk_review_product" referencedTableName="${schemaName}.products" referencedColumnNames="id"/> <rollback><dropTable tableName="${schemaName}.product_reviews"/></rollback> </changeSet> </databaseChangeLog>docker/Dockerfile:FROM liquibase/liquibase:4.33.0-alpine # Install PostgreSQL JDBC Driver RUN apk add --no-cache wget && \ mkdir -p /liquibase/drivers && \ wget -O /liquibase/drivers/postgresql-42.7.3.jar \ https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.3/postgresql-42.7.3.jar # Set LIQUIBASE_CLASSPATH ENV LIQUIBASE_CLASSPATH=/liquibase/drivers
17.3) CI Pipeline (GitHub Actions - db-migration.yml)
name: DB Migrations Pipeline
on:
push:
branches:
- main
workflow_dispatch: # Manual trigger
env:
# General Liquibase configuration variables
LIQUIBASE_COMMAND_CHANGELOG_FILE: db/master.xml
LIQUIBASE_COMMAND_DRIVER: org.postgresql.Driver
LIQUIBASE_PRO_LICENSE_KEY: ${{ secrets.LIQUIBASE_PRO_LICENSE_KEY }} # Pro feature
# Base URL pattern, specific host/port/dbName will be from env secrets
DB_BASE_URL: jdbc:postgresql://${{ secrets.DB_HOST }}:${{ secrets.DB_PORT }}/${{ secrets.DB_NAME }}
# Placeholder for schema (if needed for tenant-aware or specific schema ops)
LIQUIBASE_SCHEMA_NAME: public
jobs:
build-liquibase-image:
name: Build Custom Liquibase Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and Push Liquibase Docker Image
uses: docker/build-push-action@v5
with:
context: ./docker
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} # Tag with commit SHA
cache-from: type=gha # Cache Docker layers for faster builds
cache-to: type=gha,mode=max
validate-changelog:
name: Validate Changelogs & Run Policies (DEV)
runs-on: ubuntu-latest
needs: build-liquibase-image
environment: dev
steps:
- uses: actions/checkout@v4
- name: Pull custom Liquibase image
run: docker pull ${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }}
- name: Liquibase Validate with Policy Checks
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-w /app \
-e LIQUIBASE_COMMAND_URL="${{ secrets.DEV_DB_URL }}" \
-e LIQUIBASE_COMMAND_USERNAME="${{ secrets.DEV_DB_USERNAME }}" \
-e LIQUIBASE_COMMAND_PASSWORD="${{ secrets.DEV_DB_PASSWORD }}" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="${{ env.LIQUIBASE_COMMAND_CHANGELOG_FILE }}" \
-e LIQUIBASE_COMMAND_DRIVER="${{ env.LIQUIBASE_COMMAND_DRIVER }}" \
-e LIQUIBASE_PRO_LICENSE_KEY="${{ env.LIQUIBASE_PRO_LICENSE_KEY }}" \
-DschemaName="${{ env.LIQUIBASE_SCHEMA_NAME }}" \
${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} \
validate
# This step will fail the job if any FAIL-severity policies are violated.
# Policies would be configured in Liquibase Hub for Pro.
- name: Liquibase Status for DEV (Pre-Update Check)
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-w /app \
-e LIQUIBASE_COMMAND_URL="${{ secrets.DEV_DB_URL }}" \
-e LIQUIBASE_COMMAND_USERNAME="${{ secrets.DEV_DB_USERNAME }}" \
-e LIQUIBASE_COMMAND_PASSWORD="${{ secrets.DEV_DB_PASSWORD }}" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="${{ env.LIQUIBASE_COMMAND_CHANGELOG_FILE }}" \
-e LIQUIBASE_COMMAND_DRIVER="${{ env.LIQUIBASE_COMMAND_DRIVER }}" \
-DschemaName="${{ env.LIQUIBASE_SCHEMA_NAME }}" \
${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} \
status --verbose
deploy-to-dev:
name: Deploy to DEV
runs-on: ubuntu-latest
needs: validate-changelog
environment: dev
steps:
- uses: actions/checkout@v4
- name: Pull custom Liquibase image
run: docker pull ${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }}
- name: Run Liquibase Update for DEV
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-w /app \
-e LIQUIBASE_COMMAND_URL="${{ secrets.DEV_DB_URL }}" \
-e LIQUIBASE_COMMAND_USERNAME="${{ secrets.DEV_DB_USERNAME }}" \
-e LIQUIBASE_COMMAND_PASSWORD="${{ secrets.DEV_DB_PASSWORD }}" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="${{ env.LIQUIBASE_COMMAND_CHANGELOG_FILE }}" \
-e LIQUIBASE_COMMAND_DRIVER="${{ env.LIQUIBASE_COMMAND_DRIVER }}" \
-e LIQUIBASE_PRO_LICENSE_KEY="${{ env.LIQUIBASE_PRO_LICENSE_KEY }}" \
-DschemaName="${{ env.LIQUIBASE_SCHEMA_NAME }}" \
${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} \
update --contexts=dev --report-path=/app/reports/dev-update-report.json \
--log-level=INFO --log-file=/app/logs/dev-liquibase.log # Structured logging for Pro
- name: Upload DEV Update Report & Logs
uses: actions/upload-artifact@v4
with:
name: dev-deployment-artifacts
path: |
./reports/dev-update-report.json
./logs/dev-liquibase.log
# Note: If Liquibase Hub is used, reports/logs would be automatically sent.
deploy-to-staging:
name: Deploy to STAGING (Manual Approval)
runs-on: ubuntu-latest
needs: deploy-to-dev
environment: staging # Requires manual approval in GitHub Environments
steps:
- uses: actions/checkout@v4
- name: Pull custom Liquibase image
run: docker pull ${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }}
- name: Liquibase UpdateSQL for STAGING (Dry Run)
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-w /app \
-e LIQUIBASE_COMMAND_URL="${{ secrets.STAGING_DB_URL }}" \
-e LIQUIBASE_COMMAND_USERNAME="${{ secrets.STAGING_DB_USERNAME }}" \
-e LIQUIBASE_COMMAND_PASSWORD="${{ secrets.STAGING_DB_PASSWORD }}" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="${{ env.LIQUIBASE_COMMAND_CHANGELOG_FILE }}" \
-e LIQUIBASE_COMMAND_DRIVER="${{ env.LIQUIBASE_COMMAND_DRIVER }}" \
-DschemaName="${{ env.LIQUIBASE_SCHEMA_NAME }}" \
${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} \
updateSQL --contexts=staging > ./staging-migrations.sql # Output to host
# The updateSQL output will be available in the workflow for review
- name: Upload Staging UpdateSQL Artifact
uses: actions/upload-artifact@v4
with:
name: staging-sql-script
path: ./staging-migrations.sql
- name: Run Liquibase Update for STAGING
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-w /app \
-e LIQUIBASE_COMMAND_URL="${{ secrets.STAGING_DB_URL }}" \
-e LIQUIBASE_COMMAND_USERNAME="${{ secrets.STAGING_DB_USERNAME }}" \
-e LIQUIBASE_COMMAND_PASSWORD="${{ secrets.STAGING_DB_PASSWORD }}" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="${{ env.LIQUIBASE_COMMAND_CHANGELOG_FILE }}" \
-e LIQUIBASE_COMMAND_DRIVER="${{ env.LIQUIBASE_COMMAND_DRIVER }}" \
-e LIQUIBASE_PRO_LICENSE_KEY="${{ env.LIQUIBASE_PRO_LICENSE_KEY }}" \
-DschemaName="${{ env.LIQUIBASE_SCHEMA_NAME }}" \
${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} \
update --contexts=staging --report-path=/app/reports/staging-update-report.json \
--log-level=INFO --log-file=/app/logs/staging-liquibase.log
- name: Tag Database After Staging Deployment
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-w /app \
-e LIQUIBASE_COMMAND_URL="${{ secrets.STAGING_DB_URL }}" \
-e LIQUIBASE_COMMAND_USERNAME="${{ secrets.STAGING_DB_USERNAME }}" \
-e LIQUIBASE_COMMAND_PASSWORD="${{ secrets.STAGING_DB_PASSWORD }}" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="${{ env.LIQUIBASE_COMMAND_CHANGELOG_FILE }}" \
-e LIQUIBASE_COMMAND_DRIVER="${{ env.LIQUIBASE_COMMAND_DRIVER }}" \
-DschemaName="${{ env.LIQUIBASE_SCHEMA_NAME }}" \
${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} \
tag "release-${{ github.sha }}"
- name: Upload STAGING Update Report & Logs
uses: actions/upload-artifact@v4
with:
name: staging-deployment-artifacts
path: |
./reports/staging-update-report.json
./logs/staging-liquibase.log
deploy-to-prod:
name: Deploy to PROD (Strict Manual Approval)
runs-on: ubuntu-latest
needs: deploy-to-staging
environment: production # Requires manual approval + specific reviewers in GitHub Environments
steps:
- uses: actions/checkout@v4
- name: Pull custom Liquibase image
run: docker pull ${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }}
- name: Liquibase UpdateSQL for PROD (Critical Dry Run)
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-w /app \
-e LIQUIBASE_COMMAND_URL="${{ secrets.PROD_DB_URL }}" \
-e LIQUIBASE_COMMAND_USERNAME="${{ secrets.PROD_DB_USERNAME }}" \
-e LIQUIBASE_COMMAND_PASSWORD="${{ secrets.PROD_DB_PASSWORD }}" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="${{ env.LIQUIBASE_COMMAND_CHANGELOG_FILE }}" \
-e LIQUIBASE_COMMAND_DRIVER="${{ env.LIQUIBASE_COMMAND_DRIVER }}" \
-DschemaName="${{ env.LIQUIBASE_SCHEMA_NAME }}" \
${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} \
updateSQL --contexts=prod > ./prod-migrations.sql
- name: Upload Production UpdateSQL Artifact
uses: actions/upload-artifact@v4
with:
name: prod-sql-script
path: ./prod-migrations.sql
- name: Run Liquibase Update for PROD
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-w /app \
-e LIQUIBASE_COMMAND_URL="${{ secrets.PROD_DB_URL }}" \
-e LIQUIBASE_COMMAND_USERNAME="${{ secrets.PROD_DB_USERNAME }}" \
-e LIQUIBASE_COMMAND_PASSWORD="${{ secrets.PROD_DB_PASSWORD }}" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="${{ env.LIQUIBASE_COMMAND_CHANGELOG_FILE }}" \
-e LIQUIBASE_COMMAND_DRIVER="${{ env.LIQUIBASE_COMMAND_DRIVER }}" \
-e LIQUIBASE_PRO_LICENSE_KEY="${{ env.LIQUIBASE_PRO_LICENSE_KEY }}" \
-DschemaName="${{ env.LIQUIBASE_SCHEMA_NAME }}" \
${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} \
update --contexts=prod --report-path=/app/reports/prod-update-report.json \
--log-level=INFO --log-file=/app/logs/prod-liquibase.log
- name: Tag Production Database
run: |
docker run --rm \
-v ${{ github.workspace }}:/app \
-w /app \
-e LIQUIBASE_COMMAND_URL="${{ secrets.PROD_DB_URL }}" \
-e LIQUIBASE_COMMAND_USERNAME="${{ secrets.PROD_DB_USERNAME }}" \
-e LIQUIBASE_COMMAND_PASSWORD="${{ secrets.PROD_DB_PASSWORD }}" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="${{ env.LIQUIBASE_COMMAND_CHANGELOG_FILE }}" \
-e LIQUIBASE_COMMAND_DRIVER="${{ env.LIQUIBASE_COMMAND_DRIVER }}" \
-DschemaName="${{ env.LIQUIBASE_SCHEMA_NAME }}" \
${{ secrets.DOCKER_USERNAME }}/liquibase-pg:${{ github.sha }} \
tag "prod-release-${{ github.ref_name }}-${{ github.run_id }}"
- name: Run Post-Deployment Hook
run: ./scripts/post_deploy_hook.sh ${{ secrets.PROD_DB_URL }} ${{ secrets.PROD_DB_USERNAME }} ${{ secrets.PROD_DB_PASSWORD }} prod
- name: Upload PROD Update Report & Logs
uses: actions/upload-artifact@v4
with:
name: prod-deployment-artifacts
path: |
./reports/prod-update-report.json
./logs/prod-liquibase.log
17.4) Runbook for Failure Drill (Example: Failed Production Deployment)
Scenario: A Liquibase update to Production fails during the deploy-to-prod job.
Goal: Recover production rapidly with minimal data loss and ensure auditability.
Symptoms:
deploy-to-prodjob in GitHub Actions pipeline showsFAILURE.- Logs indicate a Liquibase
DatabaseException(e.g., a SQL syntax error, or a unique constraint violation from a data migration).
Pre-Requisites:
- A stable
prod-release-X.Ytag exists from the last successful production deployment. - Database backups are in place (point-in-time recovery).
Steps to Recovery:
Stop Further Deployments:
- Temporarily disable the CI/CD pipeline for the affected service/database.
- Inform relevant teams (application, operations) about the failure.
Analyze Failure:
- Review the GitHub Actions job logs for the
deploy-to-prodjob. - Identify the exact changeset (
id::author::logicalFilePath) that failed and the specific error message. - Examine the generated
prod-sql-scriptartifact (from theupdateSQLstep) to understand the SQL that caused the issue.
- Review the GitHub Actions job logs for the
Assess Impact & Decide Strategy:
- Was the change partially applied?: Query
DATABASECHANGELOGin production to see which changesets before the failed one were applied successfully. - Is immediate rollback necessary?:
- If the application is experiencing severe outages directly linked to the partial deployment.
- If data integrity is compromised.
- Is fix-forward possible?:
- If the change is minor and can be quickly fixed with a new, corrective changeset.
- If the partially applied changes are non-disruptive.
- For this drill, assume immediate rollback is required.
- Was the change partially applied?: Query
Initiate Rollback (Safest OSS Approach):
- Identify last stable tag: From
DATABASECHANGELOGor CI/CD history, find the tag of the last known good state (e.g.,prod-release-v1.0). - Dry Run Rollback:
# From a local dev machine or a secure CI/CD agent # Ensure you have prod credentials securely available (e.g., via ENV VARS) export LIQUIBASE_COMMAND_URL="jdbc:postgresql://<prod_host>:<prod_port>/<prod_db>" export LIQUIBASE_COMMAND_USERNAME="<prod_user>" export LIQUIBASE_COMMAND_PASSWORD="<prod_pass>" liquibase --defaults-file=liquibase.properties \ rollbackSQL --to-tag "prod-release-v1.0" \ --report-path=./reports/prod-rollback-dryrun.html > ./prod-rollback-script.sql - Review Rollback Script: Carefully examine
prod-rollback-script.sqlwith a DBA. Ensure it reverts only the desired changes and does not risk data loss. - Execute Rollback:
liquibase --defaults-file=liquibase.properties rollback --to-tag "prod-release-v1.0" \ --report-path=./reports/prod-rollback-actual.json \ --log-level=INFO --log-file=./logs/prod-rollback.log
- Identify last stable tag: From
Verify Production State:
- Query
DATABASECHANGELOGto confirm the rollbacked changesets are markedROLLED_BACK. - Perform spot checks on the database schema (tables, columns) to ensure it matches
prod-release-v1.0. - Application team confirms that the application is functional and stable with the reverted schema.
- Query
Post-Mortem & Fix:
- Conduct a post-mortem to identify the root cause of the failed deployment.
- Fix the problematic changeset in the source code (e.g., correct SQL, add missing
rollbackblock, improve preconditions). - Create a new changeset (fix-forward) to re-introduce the original desired change, potentially with more robust error handling or a phased approach.
- Test the fix thoroughly in DEV and STAGE environments.
Re-enable Deployments:
- Once the fix is validated, re-enable the CI/CD pipeline and promote the corrected changes.
17.5) Drift Remediation Example
Scenario: A DBA manually created an index idx_temp_perf on users.username in Production for a performance hotfix, but forgot to add it to Liquibase.
Pipeline Behavior: Subsequent deploy-to-prod jobs might detect this as drift (if Liquibase Pro’s continuous drift monitoring is active) or liquibase diff run manually would report it.
Remediation Steps (Adopt Strategy):
Detect Drift:
- Pro (Hub): Liquibase Hub shows a drift alert for
idx_temp_perfon the production environment. - OSS (Manual): Run
liquibase --url=<prod_url> diff --exclude-objects='DATABASECHANGELOG,DATABASECHANGELOGLOCK'locally or in a CI/CD job. It reports:Unexpected Index: PUBLIC.USERS.IDX_TEMP_PERF.
- Pro (Hub): Liquibase Hub shows a drift alert for
Generate Drift Changelog:
# Run from local or a CI/CD agent with Prod DB connection liquibase --url=<prod_url> diff-changelog \ --include-objects="index(users.idx_temp_perf)" \ --output-file=db/hotfix/20251001_prod_hotfix_idx.xmlThis generates a changeset for
createIndex.Review and Incorporate:
- Review
db/hotfix/20251001_prod_hotfix_idx.xml. It should contain:<changeSet author="generated-hotfix" id="1701388800000-1"> <createIndex indexName="idx_temp_perf" tableName="${schemaName}.users"> <column name="username"/> </createIndex> </changeSet> - Integrate this file into
master.xml(e.g., include it indb/features/user_profiles.xmlordb/common/audit_logs.xmlif generic). Commit the change to Git.
- Review
Re-deploy through Pipeline:
- Push the commit to
main. The CI/CD pipeline runs. - When
deploy-to-prodruns, it will attempt to apply20251001_prod_hotfix_idx.xml. Since the index already exists in production, Liquibase will detect this and mark the changeset as executed without re-creating it (ascreateIndexis typically idempotent by default or fails gracefully if the index exists). - After this deployment,
liquibase diffwill no longer report drift foridx_temp_perf.
- Push the commit to
17.6) Auditability Verification
DATABASECHANGELOG: After all deployments and rollbacks, query theDATABASECHANGELOGtable in each environment.This table provides a timestamped, ordered, and uniquely identified record of every changeset’s execution, includingSELECT id, author, filename, dateexecuted, orderexecuted, exectype, md5sum, deployment_id, contexts, labels FROM DATABASECHANGELOG ORDER BY dateexecuted;EXECTYPE(EXECUTED, ROLLED_BACK, MARK_RAN). TheDEPLOYMENT_ID(from Liquibase Pro or a generated UUID) links executions to a specific pipeline run.- Update Reports: Download artifacts (e.g.,
prod-update-report.json,prod-rollback-actual.json). These provide human-readable summaries of operations, critical for compliance audits. - CI/CD Logs: GitHub Actions/GitLab CI logs provide the full execution trace, linking Liquibase commands to specific commits and users.
This capstone integrates all major Liquibase features and best practices into a cohesive, enterprise-ready workflow.
18) Reference Cheat Sheets: commands, parameters, JDBC URL patterns, best-practice checklists
What you’ll learn
- Quick reference for frequently used Liquibase commands and their parameters.
- Common JDBC URL patterns for various databases.
- Docker command snippets for everyday Liquibase tasks.
- Consolidated best-practice checklists.
- Changelog scaffolds for quick starts.
Why it matters
Even experts need quick access to reference material. This section serves as a condensed, actionable guide to quickly recall commands, syntax, and best practices, saving time and ensuring correctness in daily tasks.
18.1) Most-Used Commands and Parameters
| Command | Description | Common Parameters (--parameterName=value) |
|---|---|---|
update | Applies all pending changes to the database. | changelog-file, url, username, password, contexts, labels, log-level, report-path, liquibase-pro-license-key |
updateSQL | Generates the SQL that update would run, but doesn’t execute it. | changelog-file, url, username, password, contexts, labels, output-file |
status | Displays which changesets are pending (not yet applied). | changelog-file, url, username, password, contexts, labels, verbose |
history | Displays all changesets already applied to the database. | url, username, password, verbose |
tag | Marks the current database state with a named tag. | tag-name, url, username, password |
rollback | Rolls back changesets. | url, username, password, to-tag, count, to-date |
rollback-to-tag | Rolls back all changesets applied after the specified tag. | to-tag, url, username, password |
rollback-count | Rolls back the specified number of the most recent changesets. | count, url, username, password |
diff | Compares two databases and reports differences. | url, username, password, reference-url, reference-username, reference-password, diff-exclude-objects, output-file |
diff-changelog | Generates a changelog containing differences between two DBs (or DB and CL). | url, username, password, reference-url, reference-username, reference-password, output-file, include-objects, diff-type |
validate | Checks changelog for errors and (Pro) runs policy checks. | changelog-file, url, username, password, contexts, labels |
release-locks | Forces release of a stuck DATABASECHANGELOGLOCK. | url, username, password (Use with extreme caution!) |
changelog-sync | Marks all changesets as executed without running SQL. | changelog-file, url, username, password, contexts, labels |
changelog-sync-force | Marks a specific changeset as executed and updates its checksum. | changeset-id (e.g., 1::author::path), url, username, password (Use with extreme caution!) |
lpm install | (Pro) Installs Liquibase extensions or JDBC drivers. | extension-name, version, global (installs to Liquibase lib dir) |
flow run | (Pro) Executes a defined Liquibase Flow. | flow-id, url, username, password, parameters (for flow-specific vars) |
variables:
# Assuming you set these as protected and/masked CI/CD variables in GitLab
DB_URL: ${DB_URL}
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
# For Pro features
LIQUIBASE_PRO_LICENSE_KEY: ${LIQUIBASE_PRO_LICENSE_KEY}
.liquibase_template: &liquibase_template
image: liquibase/liquibase:4.33.0-alpine # Or your custom image
before_script:
# Install PostgreSQL driver if not in a custom image
- apk add --no-cache wget unzip openjdk17
- wget -q -O /usr/share/liquibase/lib/postgresql-42.7.3.jar https://repo1.maven.org/maven2/org/postgresql/postgresql/42.7.3/postgresql-42.7.3.jar
script:
- echo "Running Liquibase command: $LIQUIBASE_COMMAND"
- liquibase --defaults-file=/builds/${CI_PROJECT_PATH}/liquibase.properties \
-e LIQUIBASE_COMMAND_URL="$DB_URL" \
-e LIQUIBASE_COMMAND_USERNAME="$DB_USERNAME" \
-e LIQUIBASE_COMMAND_PASSWORD="$DB_PASSWORD" \
-e LIQUIBASE_PRO_LICENSE_KEY="$LIQUIBASE_PRO_LICENSE_KEY" \
$LIQUIBASE_COMMAND
variables:
LIQUIBASE_COMMAND_CHANGELOG_FILE: db/master.xml # Default changelog path in container
validate_changelog:
stage: validate
<<: *liquibase_template
variables:
LIQUIBASE_COMMAND: "validate --log-level=INFO"
allow_failure: false # Pipeline fails if validation fails
# For Pro, this would also run configured Policy Checks
deploy_dev:
stage: deploy_dev
<<: *liquibase_template
variables:
LIQUIBASE_COMMAND: "update --contexts=dev --log-level=INFO"
DB_URL: ${DEV_DB_URL}
DB_USERNAME: ${DEV_DB_USERNAME}
DB_PASSWORD: ${DEV_DB_PASSWORD}
environment:
name: development
only:
- main
needs: ["validate_changelog"]
deploy_staging:
stage: deploy_staging
<<: *liquibase_template
variables:
LIQUIBASE_COMMAND: "update --contexts=staging --log-level=INFO"
DB_URL: ${STAGING_DB_URL}
DB_USERNAME: ${STAGING_DB_USERNAME}
DB_PASSWORD: ${STAGING_DB_PASSWORD}
environment:
name: staging
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual # Requires manual trigger for staging
needs: ["deploy_dev"]
deploy_prod:
stage: deploy_prod
<<: *liquibase_template
variables:
LIQUIBASE_COMMAND: "update --contexts=prod --log-level=INFO"
DB_URL: ${PROD_DB_URL}
DB_USERNAME: ${PROD_DB_USERNAME}
DB_PASSWORD: ${PROD_DB_PASSWORD}
environment:
name: production
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
when: manual # Requires manual trigger for production
needs: ["deploy_staging"]
# Conceptual Policy Configuration (for Liquibase Pro)
# Assuming policies are defined and uploaded to Liquibase Hub
# No explicit configuration here as `validate` command triggers it automatically if Hub is configured.
# Conceptual Update Reports & Structured Logging (for Liquibase Pro)
# Liquibase Pro configured to use Liquibase Hub would automatically send structured logs and reports
# You'd configure Liquibase properties for JSON logging or external log forwarding for local log files.
# -e LIQUIBASE_COMMAND_LOG_LEVEL="JSON" or relevant LIQUIBASE_LOG_FILE property in local config.
```
### 18.2) Common JDBC URL Patterns (Continued)
| Database | URL Pattern | Notes |
| :------------- | :------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------- |
| **PostgreSQL** | `jdbc:postgresql://<host>:<port>/<database>` | `port` is usually `5432`. |
| **MySQL** | `jdbc:mysql://<host>:<port>/<database>` | `port` is usually `3306`. Add `?useSSL=false&allowPublicKeyRetrieval=true` for common dev setups. |
| **MariaDB** | `jdbc:mariadb://<host>:<port>/<database>` | `port` is usually `3306`. Similar to MySQL. |
| **SQL Server** | `jdbc:sqlserver://<host>:<port>;databaseName=<database>` | `port` is usually `1433`. Instance name: `jdbc:sqlserver://<host>\:<instance>;databaseName=<database>`. |
| **Oracle** | `jdbc:oracle:thin:@<host>:<port>:<sid>` or `jdbc:oracle:thin:@//<host>:<port>/<service_name>` | `port` is usually `1521`. Use `sid` for System ID or `service_name` for service name. |
| **H2** | `jdbc:h2:mem:<database>;DB_CLOSE_DELAY=-1` | **In-Memory**: `DB_CLOSE_DELAY=-1` keeps DB alive as long as JVM runs. |
| | `jdbc:h2:file:<path_to_db>;DB_CLOSE_ON_EXIT=FALSE` | **File-based**: `<path_to_db>` is the file prefix (e.g., `/tmp/mydb`). `DB_CLOSE_ON_EXIT=FALSE` for persistence. |
### 18.3) Docker Run Patterns
Here are common Liquibase Docker command patterns:
* **Basic `update` with mounted changelog and environment variables**:
```bash
docker run --rm -v $(pwd)/my-project:/liquibase/changelog \
-e LIQUIBASE_COMMAND_URL="jdbc:postgresql://host.docker.internal:5432/testdb" \
-e LIQUIBASE_COMMAND_USERNAME="myuser" \
-e LIQUIBASE_COMMAND_PASSWORD="mypassword" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \
-e LIQUIBASE_COMMAND_DRIVER="org.postgresql.Driver" \
liquibase/liquibase:4.33.0 \
update
```
* **Using a `liquibase.properties` file inside the mounted volume**:
```bash
docker run --rm -v $(pwd)/my-project:/liquibase/changelog \
-e LIQUIBASE_COMMAND_USERNAME="myuser" \
-e LIQUIBASE_COMMAND_PASSWORD="mypassword" \
liquibase/liquibase:4.33.0 \
--defaults-file=/liquibase/changelog/liquibase.properties \
update
```
(Note: `username`/`password` are still recommended as environment variables even with a properties file for security.)
* **Running a command with a custom Docker image (containing drivers)**:
```bash
# Assuming 'my-liquibase-postgres:4.33.0' is your custom image
docker run --rm -v $(pwd)/my-project:/liquibase/changelog \
-e LIQUIBASE_COMMAND_URL="jdbc:postgresql://host.docker.internal:5432/testdb" \
-e LIQUIBASE_COMMAND_USERNAME="myuser" \
-e LIQUIBASE_COMMAND_PASSWORD="mypassword" \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \
my-liquibase-postgres:4.33.0 \
update
```
* **Generating `updateSQL` to a local file**:
```bash
docker run --rm -v $(pwd)/my-project:/liquibase/changelog \
-e LIQUIBASE_COMMAND_URL="..." \
-e LIQUIBASE_COMMAND_USERNAME="..." \
-e LIQUIBASE_COMMAND_PASSWORD="..." \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \
liquibase/liquibase:4.33.0 \
updateSQL > ./my-project/generated_sql.sql # Redirect output to a host file
```
* **Passing `--contexts` and `--labels`**:
```bash
docker run --rm -v $(pwd)/my-project:/liquibase/changelog \
-e LIQUIBASE_COMMAND_URL="..." \
-e LIQUIBASE_COMMAND_USERNAME="..." \
-e LIQUIBASE_COMMAND_PASSWORD="..." \
-e LIQUIBASE_COMMAND_CHANGELOG_FILE="master.xml" \
liquibase/liquibase:4.33.0 \
update --contexts=prod --labels="release_v1.2,bugfix"
```
### 18.4) Default `liquibase.properties` Template
```properties
# liquibase.properties - General Configuration
# Database Connection Details (often overridden by environment variables in CI/CD)
# url: jdbc:postgresql://localhost:5432/mydatabase
# driver: org.postgresql.Driver
# username: myuser
# password: mypassword
# Path to the master changelog file (relative to this properties file or absolute)
changelog-file: db/master.xml
# Logging level (INFO, DEBUG, FINE, SEVERE, WARNING, OFF)
logLevel: INFO
# Output directory for reports and logs (relative to working directory)
output-path: ./reports
# liquibaseProLicenseKey: your-pro-license-key-here # For Liquibase Pro features
# Exclude objects from diff/diff-changelog reports (comma-separated regex patterns)
# diffExcludeObjects: ^DATABASECHANGELOG$, ^DATABASECHANGELOGLOCK$, ^MY_TEMP_TABLE$
# Default contexts/labels if not specified on the command line
# contexts: dev
# labels: feature-x
18.5) Changelog Scaffolds
SQL Changelog File (.sql format)
--liquibase formatted sql
--changeset author_name:unique_changeset_id
--comment: A descriptive comment for this change
--tag: release-1.0.0
--dbms: postgresql,mysql
--contexts: dev
--labels: new-feature
CREATE TABLE example_table (
id INT PRIMARY KEY,
name VARCHAR(255)
);
--rollback DROP TABLE example_table;
--changeset another_author:another_unique_id runAlways:true
--comment: Insert or update reference data idempotently
MERGE INTO example_table (id, name) KEY(id) VALUES (1, 'Initial Data');
--rollback DELETE FROM example_table WHERE id = 1;
XML Changelog File (.xml format)
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.33.xsd">
<changeSet id="1" author="myuser" logicalFilePath="db/feature/initial.xml" contexts="dev,qa" labels="core">
<comment>Create initial core_data table</comment>
<createTable tableName="core_data">
<column name="id" type="UUID" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="value" type="text"/>
<column name="created_at" type="timestamp with time zone" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
</createTable>
<rollback>
<dropTable tableName="core_data"/>
</rollback>
</changeSet>
<changeSet id="2" author="myuser" dbms="postgresql">
<comment>Add a Postgres-specific function</comment>
<sql>
CREATE FUNCTION calculate_something(a INT, b INT) RETURNS INT AS $$
BEGIN
RETURN a + b;
END;
$$ LANGUAGE plpgsql;
</sql>
<rollback>
<sql>DROP FUNCTION calculate_something(a INT, b INT);</sql>
</rollback>
</changeSet>
<include file="db/other_changes.xml" relativeToChangelogFile="true"/>
<includeAll path="db/feature_modules" relativeToChangelogFile="true"/>
</databaseChangeLog>
YAML Changelog File (.yaml format)
databaseChangeLog:
- changeSet:
id: 1
author: myuser
logicalFilePath: db/feature/initial.yaml
contexts: [dev, qa]
labels: [core]
comment: Create initial core_data table
changes:
- createTable:
tableName: core_data
columns:
- column:
name: id
type: UUID
defaultValueComputed: gen_random_uuid()
constraints:
primaryKey: true
nullable: false
- column:
name: value
type: text
- column:
name: created_at
type: timestamp with time zone
defaultValueComputed: NOW()
constraints:
nullable: false
rollback:
- dropTable:
tableName: core_data
- changeSet:
id: 2
author: myuser
dbms: postgresql
comment: Add a Postgres-specific function
changes:
- sql: |
CREATE FUNCTION calculate_something(a INT, b INT) RETURNS INT AS $$
BEGIN
RETURN a + b;
END;
$$ LANGUAGE plpgsql;
rollback:
- sql: DROP FUNCTION calculate_something(a INT, b INT);
- include:
file: db/other_changes.yaml
relativeToChangelogFile: true
- includeAll:
path: db/feature_modules
relativeToChangelogFile: true
JSON Changelog File (.json format)
{
"databaseChangeLog": [
{
"changeSet": {
"id": "1",
"author": "myuser",
"logicalFilePath": "db/feature/initial.json",
"contexts": ["dev", "qa"],
"labels": ["core"],
"comment": "Create initial core_data table",
"changes": [
{
"createTable": {
"tableName": "core_data",
"columns": [
{
"column": {
"name": "id",
"type": "UUID",
"defaultValueComputed": "gen_random_uuid()",
"constraints": {
"primaryKey": true,
"nullable": false
}
}
},
{
"column": {
"name": "value",
"type": "text"
}
},
{
"column": {
"name": "created_at",
"type": "timestamp with time zone",
"defaultValueComputed": "NOW()",
"constraints": {
"nullable": false
}
}
}
]
}
}
],
"rollback": [
{
"dropTable": {
"tableName": "core_data"
}
}
]
}
},
{
"changeSet": {
"id": "2",
"author": "myuser",
"dbms": "postgresql",
"comment": "Add a Postgres-specific function",
"changes": [
{
"sql": "CREATE FUNCTION calculate_something(a INT, b INT) RETURNS INT AS $$\nBEGIN\n RETURN a + b;\nEND;\n$$ LANGUAGE plpgsql;"
}
],
"rollback": [
{
"sql": "DROP FUNCTION calculate_something(a INT, b INT);"
}
]
}
},
{
"include": {
"file": "db/other_changes.json",
"relativeToChangelogFile": true
}
},
{
"includeAll": {
"path": "db/feature_modules",
"relativeToChangelogFile": true
}
}
]
}
18.6) Best-Practice Checklists
Changeset Authoring Checklist
- Unique Identifier:
id,author,logicalFilePathcombination is globally unique. - Meaningful Comment: Every changeset has a clear
<comment>explaining its purpose. - Atomic Change: Each changeset performs a single, logical database change.
- Explicit Rollback: A
<rollback>block is defined for all non-trivial DDL and all DML/data migrations. - Idempotent (
runAlways): IfrunAlways="true", the changeset’s SQL is idempotent (e.g.,INSERT IGNORE,ON CONFLICT DO NOTHING,MERGE). - Preconditions: Use
preConditionsto guard against unintended execution or ensure idempotency. - Contexts/Labels: Appropriate
contextsandlabelsare applied for conditional deployments. - Database Specificity:
dbmsattribute used for platform-specific changes in multi-DB projects. - SQL Best Practices: SQL is efficient, uses correct data types, and follows naming conventions.
- No Hardcoded Secrets: Sensitive data is not hardcoded.
CI/CD Pipeline Integration Checklist
- Secure Credentials: All database credentials and sensitive keys (e.g., Liquibase Pro license) are managed as CI/CD secrets.
- Containerized Liquibase: Use a custom Docker image for Liquibase to ensure consistent environments and bundled drivers.
- Automated Validation:
liquibase validate(with Pro Policy Checks) runs early in the pipeline. - Environment-Specific Deployments: Use
contextsto target appropriate environments (DEV, STAGE, PROD). -
updateSQLDry Runs:liquibase updateSQLruns for staging/production deployments, creating reviewable SQL scripts. - Manual Approval Gates: Critical environments (STAGE, PROD) have manual approval steps.
- Database Tagging:
liquibase tagis used to mark successful deployments (e.g.,release-vX.Y) for easy rollback points. - Observability: Update reports are generated, and (Pro) structured logs are collected/forwarded to monitoring systems.
- Rollback Strategy: A clear rollback strategy is defined and documented for each environment.
- Least Privilege: Liquibase database user has minimal necessary permissions in each environment.
Troubleshooting & Safety Checklist
- Read the Error Message: Always start by fully understanding the error message.
- Check Logs: Increase
logLeveltoDEBUGwhen diagnosing issues. - Verify Connectivity: Test database connections outside Liquibase.
- Driver Check: Ensure correct JDBC driver is available and on classpath/in Docker image.
- Checksum Errors: Never
changelog-sync-forcein production. Fix-forward strategy is preferred. - Stuck Locks: Safely use
liquibase release-locksafter verifying no active Liquibase processes. - Noisy Diffs: Use
diffExcludeObjectsto filter irrelevant items. - Database Backups: Always have current database backups, especially before production deployments.
- Test Rollbacks: Routinely test
rollbackscenarios in non-production environments. - “Human in the Loop”: Implement manual reviews and approvals for high-risk changes.
This completes the comprehensive Liquibase Learning Guide from beginner to expert, covering all requested sections with the specified detail, examples, and best practices current to Liquibase 4.33 and the official Docker image. I hope this serves as an invaluable resource on your journey to becoming a Liquibase and database DevOps expert!