Introduction
Liquibase is an open-source, database-independent library for tracking, managing, and applying database schema changes. It enables version control for your database schema, making it possible to evolve your database structure alongside your application code in a controlled, repeatable manner.
Why Liquibase?
Key Benefits
- Database agnostic - supports 30+ databases including PostgreSQL, MySQL, Oracle, SQL Server
- Version control integration - schema changes are stored in source control
- Rollback support - ability to revert changes when needed
- Consistency - ensures all environments have the same schema
- Collaboration - multiple developers can work on schema changes simultaneously
- Automation - integrates with CI/CD pipelines
Setup and Dependencies
Maven Dependencies
<properties>
<liquibase.version>4.25.0</liquibase.version>
</properties>
<dependencies>
<!-- Liquibase Core -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>${liquibase.version}</version>
</dependency>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>${liquibase.version}</version>
</dependency>
<!-- Database Drivers -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.6.0</version>
</dependency>
<!-- Or for MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>${liquibase.version}</version>
<configuration>
<propertyFile>src/main/resources/liquibase.properties</propertyFile>
</configuration>
</plugin>
</plugins>
</build>
Spring Boot Configuration
# application.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/myapp
username: ${DB_USERNAME:app_user}
password: ${DB_PASSWORD:app_pass}
driver-class-name: org.postgresql.Driver
liquibase:
enabled: true
change-log: classpath:db/changelog/db.changelog-master.yaml
default-schema: public
user: ${DB_USERNAME:app_user}
password: ${DB_PASSWORD:app_pass}
url: ${spring.datasource.url}
driver-class-name: ${spring.datasource.driver-class-name}
# Additional properties
drop-first: false
contexts: development
labels: !test
rollback-file: /tmp/rollback.sql
# For multiple environments
---
spring:
config:
activate:
on-profile: production
liquibase:
contexts: production
labels: production
Project Structure
src/main/resources/db/changelog/ ├── db.changelog-master.yaml ├── changes/ │ ├── 001-initial-schema.yaml │ ├── 002-add-user-roles.yaml │ ├── 003-product-catalog.yaml │ └── 004-order-management.yaml ├── data/ │ ├── 001-initial-data.yaml │ └── 002-reference-data.yaml └── views/ ├── 001-user-summary-view.yaml └── 002-sales-report-view.yaml
Core Implementation
1. Master Changelog File
# db/changelog/db.changelog-master.yaml databaseChangeLog: - includeAll: path: db/changelog/changes/ errorIfMissingOrEmpty: true - includeAll: path: db/changelog/data/ errorIfMissingOrEmpty: false - includeAll: path: db/changelog/views/ errorIfMissingOrEmpty: false - include: file: db/changelog/stored-procedures/001-calculate-sales.yaml relativeToChangelogFile: true
2. Initial Schema Creation
# db/changelog/changes/001-initial-schema.yaml databaseChangeLog: - changeSet: id: 001-1 author: john.doe changes: - createTable: tableName: users columns: - column: name: id type: UUID constraints: primaryKey: true nullable: false defaultValueComputed: gen_random_uuid() - column: name: username type: VARCHAR(50) constraints: unique: true nullable: false - column: name: email type: VARCHAR(255) constraints: nullable: false - column: name: password_hash type: VARCHAR(255) constraints: nullable: false - column: name: first_name type: VARCHAR(100) - column: name: last_name type: VARCHAR(100) - column: name: created_at type: TIMESTAMP defaultValueComputed: CURRENT_TIMESTAMP - column: name: updated_at type: TIMESTAMP defaultValueComputed: CURRENT_TIMESTAMP - createTable: tableName: roles columns: - column: name: id type: BIGINT autoIncrement: true constraints: primaryKey: true nullable: false - column: name: name type: VARCHAR(50) constraints: unique: true nullable: false - column: name: description type: VARCHAR(255) - createTable: tableName: user_roles columns: - column: name: user_id type: UUID constraints: nullable: false foreignKeyName: fk_user_roles_user references: users(id) deleteCascade: true - column: name: role_id type: BIGINT constraints: nullable: false foreignKeyName: fk_user_roles_role references: roles(id) deleteCascade: true constraints: primaryKey: columnNames: user_id, role_id constraintName: pk_user_roles - createIndex: indexName: idx_users_email tableName: users columns: - column: name: email descending: false - createSequence: sequenceName: user_id_seq startValue: 1 incrementBy: 1
3. Adding New Tables and Modifications
# db/changelog/changes/002-add-user-roles.yaml databaseChangeLog: - changeSet: id: 002-1 author: jane.smith changes: - addColumn: tableName: users columns: - column: name: phone_number type: VARCHAR(20) - column: name: date_of_birth type: DATE - modifyDataType: tableName: users columnName: email newDataType: VARCHAR(320) - addNotNullConstraint: tableName: users columnName: first_name columnDataType: VARCHAR(100) - dropNotNullConstraint: tableName: users columnName: last_name columnDataType: VARCHAR(100)
4. Complex Schema Changes
# db/changelog/changes/003-product-catalog.yaml databaseChangeLog: - changeSet: id: 003-1 author: mike.wilson changes: - createTable: tableName: categories columns: - column: name: id type: UUID defaultValueComputed: gen_random_uuid() constraints: primaryKey: true nullable: false - column: name: name type: VARCHAR(100) constraints: nullable: false - column: name: description type: TEXT - column: name: parent_category_id type: UUID - column: name: created_at type: TIMESTAMP defaultValueComputed: CURRENT_TIMESTAMP - addForeignKeyConstraint: baseTableName: categories baseColumnNames: parent_category_id constraintName: fk_categories_parent referencedTableName: categories referencedColumnNames: id - createTable: tableName: products columns: - column: name: id type: UUID defaultValueComputed: gen_random_uuid() constraints: primaryKey: true nullable: false - column: name: sku type: VARCHAR(50) constraints: unique: true nullable: false - column: name: name type: VARCHAR(255) constraints: nullable: false - column: name: description type: TEXT - column: name: price type: DECIMAL(10, 2) constraints: nullable: false - column: name: category_id type: UUID constraints: nullable: false - column: name: stock_quantity type: INTEGER defaultValue: 0 - column: name: is_active type: BOOLEAN defaultValue: true - column: name: created_at type: TIMESTAMP defaultValueComputed: CURRENT_TIMESTAMP - column: name: updated_at type: TIMESTAMP defaultValueComputed: CURRENT_TIMESTAMP - addForeignKeyConstraint: baseTableName: products baseColumnNames: category_id constraintName: fk_products_category referencedTableName: categories referencedColumnNames: id - createIndex: indexName: idx_products_sku tableName: products columns: - column: name: sku - createIndex: indexName: idx_products_category tableName: products columns: - column: name: category_id - createIndex: indexName: idx_products_active tableName: products columns: - column: name: is_active
5. Data Migrations and Seeding
# db/changelog/data/001-initial-data.yaml databaseChangeLog: - changeSet: id: data-001-1 author: system changes: - loadData: tableName: roles file: db/changelog/data/roles.csv separator: "," relativeToChangelogFile: true - insert: tableName: users columns: - column: name: id value: "550e8400-e29b-41d4-a716-446655440000" - column: name: username value: "admin" - column: name: email value: "[email protected]" - column: name: password_hash value: "$2a$10$hashedpassword" - column: name: first_name value: "System" - column: name: last_name value: "Administrator" - insert: tableName: user_roles columns: - column: name: user_id value: "550e8400-e29b-41d4-a716-446655440000" - column: name: role_id value: 1 - loadUpdateData: tableName: products primaryKey: sku file: db/changelog/data/products.csv relativeToChangelogFile: true
6. Advanced Changes with Rollback
# db/changelog/changes/004-order-management.yaml databaseChangeLog: - changeSet: id: 004-1 author: sarah.johnson changes: - createTable: tableName: orders columns: - column: name: id type: UUID defaultValueComputed: gen_random_uuid() constraints: primaryKey: true nullable: false - column: name: user_id type: UUID constraints: nullable: false - column: name: order_number type: VARCHAR(50) constraints: unique: true nullable: false - column: name: total_amount type: DECIMAL(12, 2) constraints: nullable: false - column: name: status type: VARCHAR(20) defaultValue: "PENDING" - column: name: created_at type: TIMESTAMP defaultValueComputed: CURRENT_TIMESTAMP - column: name: updated_at type: TIMESTAMP defaultValueComputed: CURRENT_TIMESTAMP rollback: - dropTable: tableName: orders - addForeignKeyConstraint: baseTableName: orders baseColumnNames: user_id constraintName: fk_orders_user referencedTableName: users referencedColumnNames: id rollback: - dropForeignKeyConstraint: baseTableName: orders constraintName: fk_orders_user - createTable: tableName: order_items columns: - column: name: id type: UUID defaultValueComputed: gen_random_uuid() constraints: primaryKey: true nullable: false - column: name: order_id type: UUID constraints: nullable: false - column: name: product_id type: UUID constraints: nullable: false - column: name: quantity type: INTEGER constraints: nullable: false - column: name: unit_price type: DECIMAL(10, 2) constraints: nullable: false - column: name: total_price type: DECIMAL(10, 2) constraints: nullable: false rollback: - dropTable: tableName: order_items - addForeignKeyConstraint: baseTableName: order_items baseColumnNames: order_id constraintName: fk_order_items_order referencedTableName: orders referencedColumnNames: id - addForeignKeyConstraint: baseTableName: order_items baseColumnNames: product_id constraintName: fk_order_items_product referencedTableName: products referencedColumnNames: id - createSequence: sequenceName: order_number_seq startValue: 1000 incrementBy: 1 rollback: - dropSequence: sequenceName: order_number_seq
Advanced Features
1. Custom SQL Changesets
# db/changelog/changes/005-custom-sql.yaml databaseChangeLog: - changeSet: id: 005-1 author: david.brown changes: - sql: sql: | CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ language 'plpgsql' - sql: sql: | CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column() - sql: sql: | CREATE TRIGGER update_products_updated_at BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION update_updated_at_column() - sql: sql: | CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION update_updated_at_column() - createView: viewName: user_order_summary selectQuery: | SELECT u.id as user_id, u.username, u.email, COUNT(o.id) as total_orders, COALESCE(SUM(o.total_amount), 0) as total_spent, MAX(o.created_at) as last_order_date FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.username, u.email - createProcedure: procedureName: calculate_monthly_sales procedureText: | CREATE OR REPLACE FUNCTION calculate_monthly_sales(year_month TEXT) RETURNS TABLE( product_name VARCHAR(255), total_quantity BIGINT, total_revenue DECIMAL(12, 2) ) AS $$ BEGIN RETURN QUERY SELECT p.name as product_name, SUM(oi.quantity) as total_quantity, SUM(oi.total_price) as total_revenue FROM order_items oi JOIN products p ON oi.product_id = p.id JOIN orders o ON oi.order_id = o.id WHERE to_char(o.created_at, 'YYYY-MM') = year_month GROUP BY p.id, p.name ORDER BY total_revenue DESC; END; $$ LANGUAGE plpgsql
2. Conditional Changesets
# db/changelog/changes/006-conditional-changes.yaml databaseChangeLog: - changeSet: id: 006-1 author: lisa.green changes: - precondition: onFail: MARK_RAN dbms: type: postgresql - addColumn: tableName: users columns: - column: name: metadata type: JSONB - precondition: onFail: MARK_RAN sqlCheck: expectedResult: 0 sql: SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'tags' - addColumn: tableName: products columns: - column: name: tags type: VARCHAR(255)[] - precondition: onFail: HALT tableExists: tableName: audit_log - createTable: tableName: audit_log_backup columns: - column: name: id type: BIGSERIAL constraints: primaryKey: true nullable: false - column: name: table_name type: VARCHAR(100) - column: name: record_id type: VARCHAR(100) - column: name: action type: VARCHAR(50) - column: name: old_values type: JSONB - column: name: new_values type: JSONB - column: name: changed_at type: TIMESTAMP defaultValueComputed: CURRENT_TIMESTAMP - column: name: changed_by type: VARCHAR(100)
3. Context and Labels
# db/changelog/changes/007-context-sensitive.yaml databaseChangeLog: - changeSet: id: 007-1 author: system context: test,development changes: - insert: tableName: configuration columns: - column: name: key value: "debug_mode" - column: name: value value: "true" - column: name: environment value: "development" - changeSet: id: 007-2 author: system context: production changes: - insert: tableName: configuration columns: - column: name: key value: "debug_mode" - column: name: value value: "false" - column: name: environment value: "production" - changeSet: id: 007-3 author: admin labels: performance,indexing changes: - createIndex: indexName: idx_orders_user_status tableName: orders columns: - column: name: user_id - column: name: status - createIndex: indexName: idx_order_items_order_product tableName: order_items columns: - column: name: order_id - column: name: product_id
Spring Boot Integration
1. Configuration Properties
package com.example.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "spring.liquibase")
public class LiquibaseProperties {
private boolean enabled = true;
private String changeLog;
private String contexts;
private String labels;
private String defaultSchema;
private String liquibaseSchema;
private String liquibaseTablespace;
private boolean dropFirst = false;
private boolean testRollbackOnUpdate = false;
// Getters and setters
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getChangeLog() { return changeLog; }
public void setChangeLog(String changeLog) { this.changeLog = changeLog; }
public String getContexts() { return contexts; }
public void setContexts(String contexts) { this.contexts = contexts; }
// ... other getters and setters
}
2. Custom Liquibase Configuration
package com.example.config;
import liquibase.integration.spring.SpringLiquibase;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import javax.sql.DataSource;
@Configuration
public class LiquibaseConfig {
@Autowired
private Environment environment;
@Autowired
private LiquibaseProperties liquibaseProperties;
@Bean
public SpringLiquibase liquibase(DataSource dataSource) {
SpringLiquibase liquibase = new SpringLiquibase();
liquibase.setDataSource(dataSource);
liquibase.setChangeLog(liquibaseProperties.getChangeLog());
liquibase.setContexts(liquibaseProperties.getContexts());
liquibase.setLabels(liquibaseProperties.getLabels());
liquibase.setDefaultSchema(liquibaseProperties.getDefaultSchema());
liquibase.setLiquibaseSchema(liquibaseProperties.getLiquibaseSchema());
liquibase.setDropFirst(liquibaseProperties.isDropFirst());
liquibase.setShouldRun(liquibaseProperties.isEnabled());
// Additional configuration
liquibase.setChangeLogParameters(Collections.singletonMap(
"environment", environment.getActiveProfiles().length > 0 ?
environment.getActiveProfiles()[0] : "default"
));
return liquibase;
}
}
3. Programmatic Changeset Execution
package com.example.service;
import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.sql.DataSource;
import java.sql.Connection;
@Service
public class DatabaseMigrationService {
private static final Logger logger = LoggerFactory.getLogger(DatabaseMigrationService.class);
private final DataSource dataSource;
private final String changeLogFile = "db/changelog/db.changelog-master.yaml";
public DatabaseMigrationService(DataSource dataSource) {
this.dataSource = dataSource;
}
public void updateDatabase() {
try (Connection connection = dataSource.getConnection()) {
Database database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(new JdbcConnection(connection));
Liquibase liquibase = new Liquibase(changeLogFile,
new ClassLoaderResourceAccessor(), database);
liquibase.update(new Contexts(), new LabelExpression());
logger.info("Database migration completed successfully");
} catch (Exception e) {
logger.error("Database migration failed", e);
throw new RuntimeException("Migration failed", e);
}
}
public void rollback(int changesToRollback) {
try (Connection connection = dataSource.getConnection()) {
Database database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(new JdbcConnection(connection));
Liquibase liquibase = new Liquibase(changeLogFile,
new ClassLoaderResourceAccessor(), database);
liquibase.rollback(changesToRollback, new Contexts(), new LabelExpression());
logger.info("Rollback completed for {} changes", changesToRollback);
} catch (Exception e) {
logger.error("Rollback failed", e);
throw new RuntimeException("Rollback failed", e);
}
}
public void generateRollbackSQL(int changesToRollback) {
try (Connection connection = dataSource.getConnection()) {
Database database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(new JdbcConnection(connection));
Liquibase liquibase = new Liquibase(changeLogFile,
new ClassLoaderResourceAccessor(), database);
String rollbackSql = liquibase.rollback(changesToRollback,
new Contexts(), new LabelExpression(),
new liquibase.changelog.ChangeLogParameters(database),
new liquibase.output.OutputWriter() {
private final StringBuilder sql = new StringBuilder();
@Override
public void write(String message) {
sql.append(message);
}
@Override
public void close() {}
@Override
public String toString() {
return sql.toString();
}
});
logger.info("Rollback SQL generated:\n{}", rollbackSql);
} catch (Exception e) {
logger.error("Failed to generate rollback SQL", e);
throw new RuntimeException("Failed to generate rollback SQL", e);
}
}
}
Testing
1. Integration Test Configuration
package com.example.test;
import liquibase.Contexts;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import javax.sql.DataSource;
import java.sql.Connection;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@ActiveProfiles("test")
class LiquibaseIntegrationTest {
@Autowired
private DataSource dataSource;
@BeforeEach
void setUp() throws Exception {
// Liquibase runs automatically via Spring Boot configuration
// Additional setup if needed
}
@Test
void testDatabaseIsInitialized() throws Exception {
try (Connection connection = dataSource.getConnection()) {
// Verify tables exist
var statement = connection.createStatement();
var resultSet = statement.executeQuery(
"SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema = 'public'"
);
if (resultSet.next()) {
int tableCount = resultSet.getInt(1);
assertTrue(tableCount > 0, "Database should have tables created by Liquibase");
}
}
}
@Test
void testChangelogIsValid() throws Exception {
try (Connection connection = dataSource.getConnection()) {
Database database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(new JdbcConnection(connection));
Liquibase liquibase = new Liquibase("db/changelog/db.changelog-master.yaml",
new ClassLoaderResourceAccessor(), database);
// This will validate the changelog without applying changes
liquibase.validate();
assertTrue(true, "Changelog should be valid");
}
}
}
2. Test-Specific Changesets
# db/changelog/test/001-test-data.yaml databaseChangeLog: - changeSet: id: test-001-1 author: test context: test changes: - sql: sql: "DELETE FROM users" # Clean slate - insert: tableName: users columns: - column: name: id value: "11111111-1111-1111-1111-111111111111" - column: name: username value: "testuser" - column: name: email value: "[email protected]" - column: name: password_hash value: "hashedpassword" - column: name: first_name value: "Test" - column: name: last_name value: "User" - insert: tableName: products columns: - column: name: id value: "22222222-2222-2222-2222-222222222222" - column: name: sku value: "TEST-SKU-001" - column: name: name value: "Test Product" - column: name: price value: 99.99 - column: name: category_id value: "33333333-3333-3333-3333-333333333333"
Best Practices
1. Changeset Naming and Organization
# Best practice: One changeset per logical change databaseChangeLog: - changeSet: id: feature-001-user-profile-extension author: developer.name changes: - addColumn: tableName: users columns: - column: name: profile_picture_url type: VARCHAR(500) - changeSet: id: feature-001-user-profile-index author: developer.name changes: - createIndex: indexName: idx_users_profile_picture tableName: users columns: - column: name: profile_picture_url
2. Rollback Strategy
databaseChangeLog: - changeSet: id: safe-table-creation author: team changes: - createTable: tableName: audit_log columns: # ... columns rollback: - dropTable: tableName: audit_log - changeSet: id: data-migration-with-rollback author: team changes: - sql: sql: "INSERT INTO new_table SELECT * FROM old_table" rollback: - sql: sql: "DELETE FROM new_table" - changeSet: id: complex-rollback author: team changes: - customChange: class: com.example.liquibase.CustomDataMigration rollback: - sql: sql: "DELETE FROM migrated_data WHERE batch_id = '2024-01'"
3. Database-Specific Changes
databaseChangeLog: - changeSet: id: multi-database-support author: team changes: - createTable: tableName: application_log columns: - column: name: id type: BIGSERIAL autoIncrement: true constraints: primaryKey: true nullable: false - column: name: message type: CLOB - column: name: created_at type: TIMESTAMP defaultValueComputed: postgresql: CURRENT_TIMESTAMP mysql: NOW() h2: CURRENT_TIMESTAMP oracle: SYSTIMESTAMP - addColumn: tableName: application_log columns: - column: name: severity type: VARCHAR(20) defaultValue: "INFO" - modifyDataType: tableName: application_log columnName: message newDataType: postgresql: TEXT mysql: LONGTEXT h2: CLOB oracle: CLOB
CI/CD Integration
1. Maven Plugin Configuration
<plugin>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-maven-plugin</artifactId>
<version>${liquibase.version}</version>
<configuration>
<propertyFile>src/main/resources/liquibase.properties</propertyFile>
<promptOnNonLocalDatabase>false</promptOnNonLocalDatabase>
</configuration>
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
</dependencies>
</plugin>
2. Liquibase Properties
# liquibase.properties driver=org.postgresql.Driver url=jdbc:postgresql://localhost:5432/myapp username=app_user password=app_pass changeLogFile=db/changelog/db.changelog-master.yaml contexts=production labels=!test defaultSchemaName=public liquibase.schemaName=public
Conclusion
Liquibase provides a robust, database-agnostic solution for managing schema evolution in Java applications. Key benefits include:
- Version-controlled database changes that evolve with your application
- Consistent environments across development, testing, and production
- Rollback capabilities for safe deployments
- Collaboration support for team development
- Integration with Spring Boot and CI/CD pipelines
Best practices for successful Liquibase implementation:
- One logical change per changeset - keeps changes granular and manageable
- Meaningful changeset IDs - use descriptive names with feature references
- Always provide rollback instructions - enables safe deployments
- Use contexts and labels for environment-specific changes
- Test migrations thoroughly - including rollback scenarios
- Monitor database changelog - track what changes have been applied
By following these patterns, you can achieve reliable, repeatable database deployments that scale with your application's complexity and team size.