Grails Database Migration

In this guide we will learn how to use the Grails Database Migration Plugin

Authors: Nirav Assar, Sergio del Amo, Sanjana

Grails Version: 8

1 Introduction

Learn how to evolve a PostgreSQL schema safely across releases with the Grails Database Migration plugin and Liquibase changelogs: baseline from GORM domains, make columns nullable, add columns, redesign tables, and migrate existing data with custom SQL - without relying on Hibernate dbCreate auto-DDL.

This is the production follow-on to the Data Access with GORM guide: "Now that I’ve modeled my data, how do I evolve the schema safely across releases?"

This guide follows the standard Grails guides layout: work in the initial/ project and compare your progress with complete/.

2 What you will need

  • Approximately 45 minutes

  • JDK 21 (Apache Grails 8 requires Java 21)

  • A Grails installation or the bundled Gradle / Grails wrappers in initial/ and complete/

  • Docker - required for integration tests (Testcontainers PostgreSQL)

  • PostgreSQL 16 on localhost:5432 - required for ./gradlew bootRun in both initial/ and complete/ (default database devDb; see grails-app/conf/application.yml)

3 Getting Started

Clone the repository and verify tests pass:

git clone -b grails8 https://github.com/grails-guides/grails-database-migration.git
cd grails-database-migration/initial
./gradlew test

The initial/ project is a Grails 8 REST API starter with the database-migration plugin and a Person domain - but no Liquibase changelogs yet. Unit tests cover Person constraints; updateOnStart stays false in test config until you generate the baseline changelog.

To skip ahead, cd ../complete and run the same commands - that tree contains the finished migrations, domains, and tests.

Verify CI

Both initial and complete must pass in CI (see .github/workflows/grails8.yml).

4 Plugin setup

Grails 8 uses org.apache.grails:grails-data-hibernate5-dbmigration from the Grails BOM - not the legacy org.grails.plugins:database-migration coordinate.

Add the dependency in build.gradle:

build.gradle
    implementation "org.apache.grails:grails-data-hibernate5-dbmigration"

Disable Hibernate auto-DDL and apply migrations on startup in grails-app/conf/application.yml:

grails-app/conf/application.yml
environments:
  development:
    dataSource:
      dbCreate: none
      url: jdbc:postgresql://localhost:5432/devDb?tcpKeepAlive=true
grails:
  plugin:
    databasemigration:
      updateOnStart: true
      updateOnStartFileName: changelog.groovy

dbCreate: none ensures schema changes flow through Liquibase changelogs only. updateOnStart: true is convenient for local and single-instance apps; for production with multiple instances, apply migrations once (for example with ./grailsw dbm-update) before rolling out the new app version.

5 Baseline the schema

Start with a Person domain:

grails-app/domain/example/Person.groovy
package example

import grails.persistence.Entity

@Entity
class Person {

    String name
    Integer age

    static constraints = {
        name nullable: false
        age nullable: false
    }
}

Generate the initial changelog from GORM domains:

./grailsw dbm-generate-gorm-changelog changelog.groovy

Move the generated changeset into its own file and reference it from the main changelog:

grails-app/migrations/create-person-table.groovy
databaseChangeLog = {

    changeSet(author: 'guide (generated)', id: 'create-person-table-1') {
        createTable(tableName: 'person') {
            column(autoIncrement: true, name: 'id', type: 'BIGINT') {
                constraints(nullable: false, primaryKey: true, primaryKeyName: 'personPK')
            }
            column(name: 'version', type: 'BIGINT') {
                constraints(nullable: false)
            }
            column(name: 'age', type: 'INTEGER') {
                constraints(nullable: false)
            }
            column(name: 'name', type: 'VARCHAR(255)') {
                constraints(nullable: false)
            }
        }
    }
}
grails-app/migrations/changelog.groovy
databaseChangeLog = {

    include file: 'create-person-table.groovy'
}

Apply the migration:

./grailsw dbm-update

Liquibase creates DATABASECHANGELOG, DATABASECHANGELOGLOCK, and the person table.

6 Make a column nullable

Make age optional on the Person domain, then diff the change into a new migration:

grails-app/domain/example/Person.groovy
package example

import grails.persistence.Entity

@Entity
class Person {

    String name
    Integer age

    static constraints = {
        name nullable: false
        age nullable: true
    }
}

Generate a migration from the domain diff:

./grailsw dbm-gorm-diff change-age-constraint-to-nullable.groovy --add
grails-app/migrations/change-age-constraint-to-nullable.groovy
databaseChangeLog = {

    changeSet(author: 'guide (generated)', id: 'change-age-nullable-1') {
        dropNotNullConstraint(columnDataType: 'integer', columnName: 'age', tableName: 'person')
    }
}

Include the new file in changelog.groovy and apply:

./grailsw dbm-update

This changeset is reversible. Roll back the last applied changeset with:

./grailsw dbm-rollback-count 1

7 Add address columns

Add address columns directly on person before splitting them into a separate table:

grails-app/migrations/add-address-fields-to-person.groovy
databaseChangeLog = {

    changeSet(author: 'guide (generated)', id: 'add-address-city-1') {
        addColumn(tableName: 'person') {
            column(name: 'city', type: 'VARCHAR(255)')
        }
    }

    changeSet(author: 'guide (generated)', id: 'add-address-street-1') {
        addColumn(tableName: 'person') {
            column(name: 'street_name', type: 'VARCHAR(255)')
        }
    }

    changeSet(author: 'guide (generated)', id: 'add-address-zip-1') {
        addColumn(tableName: 'person') {
            column(name: 'zip_code', type: 'VARCHAR(255)')
        }
    }
}

Include the file in changelog.groovy and apply:

./grailsw dbm-update

This intermediate step mirrors a real-world scenario where address data lives on person before normalization.

8 Redesign tables and migrate data

Introduce an Address domain and migrate existing column data with custom SQL. Address ownership is mandatory (person is required), matching person_id NOT NULL in the database:

grails-app/domain/example/Address.groovy
package example

import grails.persistence.Entity

@Entity
class Address {

    Person person
    String streetName
    String city
    String zipCode

    static belongsTo = [person: Person]

    static mapping = {
        id generator: 'identity'
    }

    static constraints = {
        person nullable: false
        streetName nullable: true
        city nullable: true
        zipCode nullable: true
    }
}

This redesign is an expand-migrate-contract sequence: create address, copy data, then drop the denormalized person columns. Guard the destructive steps with preConditions, and supply explicit rollback {} blocks so dbm-rollback can restore columns and data:

grails-app/migrations/create-address-table.groovy
databaseChangeLog = {

    changeSet(author: 'guide (generated)', id: 'create-address-table-1') {
        preConditions(onFail: 'HALT') {
            tableExists(tableName: 'person')
            not {
                tableExists(tableName: 'address')
            }
        }
        createTable(tableName: 'address') {
            column(autoIncrement: true, name: 'id', type: 'BIGINT') {
                constraints(nullable: false, primaryKey: true, primaryKeyName: 'addressPK')
            }
            column(name: 'version', type: 'BIGINT') {
                constraints(nullable: false)
            }
            column(name: 'person_id', type: 'BIGINT') {
                constraints(nullable: false)
            }
            column(name: 'street_name', type: 'VARCHAR(255)')
            column(name: 'city', type: 'VARCHAR(255)')
            column(name: 'zip_code', type: 'VARCHAR(255)')
        }
    }

    changeSet(author: 'guide (generated)', id: 'create-address-fk-1') {
        preConditions(onFail: 'HALT') {
            tableExists(tableName: 'address')
            tableExists(tableName: 'person')
        }
        addForeignKeyConstraint(
            baseColumnNames: 'person_id',
            baseTableName: 'address',
            constraintName: 'fk_address_person',
            referencedColumnNames: 'id',
            referencedTableName: 'person'
        )
    }

    changeSet(author: 'guide (generated)', id: 'migrate-address-data-1') {
        preConditions(onFail: 'HALT') {
            tableExists(tableName: 'address')
            columnExists(tableName: 'person', columnName: 'street_name')
            columnExists(tableName: 'person', columnName: 'city')
            columnExists(tableName: 'person', columnName: 'zip_code')
        }
        sql('''insert into address (version, person_id, street_name, city, zip_code)
              select 0, id, street_name, city, zip_code from person
              where street_name is not null or city is not null or zip_code is not null''')
        rollback {
            sql('delete from address')
        }
    }

    changeSet(author: 'guide (generated)', id: 'drop-person-city-1') {
        preConditions(onFail: 'HALT') {
            tableExists(tableName: 'address')
            columnExists(tableName: 'person', columnName: 'city')
        }
        dropColumn(columnName: 'city', tableName: 'person')
        rollback {
            addColumn(tableName: 'person') {
                column(name: 'city', type: 'VARCHAR(255)')
            }
            sql('''update person p set city = a.city from address a where a.person_id = p.id''')
        }
    }

    changeSet(author: 'guide (generated)', id: 'drop-person-street-1') {
        preConditions(onFail: 'HALT') {
            tableExists(tableName: 'address')
            columnExists(tableName: 'person', columnName: 'street_name')
        }
        dropColumn(columnName: 'street_name', tableName: 'person')
        rollback {
            addColumn(tableName: 'person') {
                column(name: 'street_name', type: 'VARCHAR(255)')
            }
            sql('''update person p set street_name = a.street_name from address a where a.person_id = p.id''')
        }
    }

    changeSet(author: 'guide (generated)', id: 'drop-person-zip-1') {
        preConditions(onFail: 'HALT') {
            tableExists(tableName: 'address')
            columnExists(tableName: 'person', columnName: 'zip_code')
        }
        dropColumn(columnName: 'zip_code', tableName: 'person')
        rollback {
            addColumn(tableName: 'person') {
                column(name: 'zip_code', type: 'VARCHAR(255)')
            }
            sql('''update person p set zip_code = a.zip_code from address a where a.person_id = p.id''')
        }
    }
}

Include the file in changelog.groovy and apply:

./grailsw dbm-update

If ./grailsw is unavailable, use ./gradlew runCommand "-Pargs=dbm-update".

Expose persisted data through a read-only REST endpoint:

grails-app/controllers/example/UrlMappings.groovy
package example

class UrlMappings {

    static mappings = {
        "/$controller/$action?/$id?(.$format)?"{
            constraints {
            }
        }

        "/api/persons"(controller: 'person', action: 'index', method: 'GET')

        "/"(controller: 'application', action: 'index')
        "500"(view: '/error')
        "404"(view: '/notFound')
    }
}
grails-app/controllers/example/PersonController.groovy
package example

class PersonController {

    static responseFormats = ['json']
    static allowedMethods = [index: 'GET']

    def index(Integer max) {
        params.max = Math.min(max ?: 10, 100)
        respond Person.list(params), model: [personCount: Person.count()]
    }
}

9 Release runbook

Before you apply pending changesets, inspect and preview them.

Check what Liquibase thinks is pending:

./grailsw dbm-status

Generate the SQL that would run, without applying it:

./grailsw dbm-update-sql pending.sql

Review pending.sql, then apply:

./grailsw dbm-update

updateOnStart: true is fine for local development and single-instance deployments. In production, prefer running dbm-update once as a release step before you roll out multiple application instances, so every node boots against an already-migrated schema.

If ./grailsw is unavailable, the Gradle form works for CI and automation:

./gradlew runCommand "-Pargs=dbm-status"
./gradlew runCommand "-Pargs=dbm-update-sql pending.sql"
./gradlew runCommand "-Pargs=dbm-update"

10 Designed rollback

Treat rollback as a designed capability, not an afterthought.

Simple, auto-reversible changesets (such as dropNotNullConstraint) can use:

./grailsw dbm-rollback-count 1

For multi-step redesigns, write explicit rollback {} blocks (as in create-address-table.groovy) so Liquibase can re-add dropped columns and restore data from address.

Tag a known-good release before a risky update:

./grailsw dbm-tag before-address-redesign

Preview the rollback SQL to that tag:

./grailsw dbm-rollback-sql before-address-redesign

Execute the rollback when you need to:

./grailsw dbm-rollback before-address-redesign

11 Existing database onboarding

Greenfield apps can baseline from GORM domains with dbm-generate-gorm-changelog. Existing production databases usually need a different path.

Snapshot the live schema:

./grailsw dbm-generate-changelog existing-schema.groovy

Mark those changesets as already applied without running DDL again:

./grailsw dbm-changelog-sync

After that, new releases only append forward migrations. Use dbm-generate-gorm-changelog when the database should match domains from scratch; use dbm-generate-changelog + dbm-changelog-sync when the schema already exists and you must not recreate it.

12 Changelog discipline

Keep changelog history append-only and immutable.

  • Include migration files in release order from changelog.groovy

  • Prefer one logical change per file, with a unique author + id

  • Never edit a changeset that has already been applied in any environment - add a corrective changeset instead

  • dbm-clear-checksums recalculates checksums after an emergency edit; it is not a substitute for append-only discipline

Destructive redesigns should also declare preConditions(onFail: 'HALT') so the wrong ordering or schema drift fails loudly instead of damaging data.

13 Testing migrations

Unit tests

Domain constraints after schema evolution:

src/test/groovy/example/PersonSpec.groovy
package example

import grails.testing.gorm.DomainUnitTest
import spock.lang.Specification

class PersonSpec extends Specification implements DomainUnitTest<Person> {

    void 'name is required'() {
        when:
        def person = new Person()

        then:
        !person.validate()
        person.errors['name']
    }

    void 'age is optional'() {
        when:
        def person = new Person(name: 'Ada')

        then:
        person.validate()
    }
}
src/test/groovy/example/AddressSpec.groovy
package example

import grails.testing.gorm.DomainUnitTest
import spock.lang.Specification

class AddressSpec extends Specification implements DomainUnitTest<Address> {

    void 'person is required'() {
        when:
        def address = new Address()

        then:
        !address.validate()
        address.errors['person'].code == 'nullable'
    }

    void 'address fields are optional when person is set'() {
        given:
        mockDomain(Person)

        when:
        def person = new Person(name: 'Ada', age: 36).save(flush: true)
        def address = new Address(person: person)

        then:
        address.validate()
    }
}

Integration tests

Assert Liquibase tracking tables, final schema shape, GORM persistence, and a migration-path proof that legacy person address columns are copied into address before those columns are dropped:

src/integration-test/groovy/example/DatabaseMigrationIntegrationSpec.groovy
package example

import grails.gorm.transactions.Rollback
import grails.testing.mixin.integration.Integration
import groovy.sql.Sql
import org.springframework.beans.factory.annotation.Autowired
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.utility.DockerImageName
import spock.lang.Shared
import spock.lang.Specification

import javax.sql.DataSource
import java.sql.Connection
import java.sql.DriverManager

@Integration
class DatabaseMigrationIntegrationSpec extends Specification {

    @Shared
    static PostgreSQLContainer postgres = new PostgreSQLContainer<>(DockerImageName.parse('postgres:16-alpine'))
            .withDatabaseName('migrate_path')
            .withUsername('test')
            .withPassword('test')

    @Autowired
    DataSource dataSource

    def setupSpec() {
        postgres.start()
    }

    def cleanupSpec() {
        if (postgres.running) {
            postgres.stop()
        }
    }

    void 'Liquibase tracking tables exist on a clean database'() {
        expect:
        tableExists('databasechangelog')
        tableExists('databasechangeloglock')
    }

    void 'person table exists with expected columns and no address columns'() {
        expect:
        tableExists('person')
        columnNames('person').containsAll(['id', 'version', 'name', 'age'])
        !columnNames('person').contains('street_name')
        !columnNames('person').contains('city')
        !columnNames('person').contains('zip_code')
    }

    void 'address table exists with person_id foreign key'() {
        expect:
        tableExists('address')
        columnNames('address').containsAll(['id', 'version', 'person_id', 'street_name', 'city', 'zip_code'])
        foreignKeyExists('address', 'person_id', 'person', 'id')
    }

    @Rollback
    void 'GORM can save Person and Address after migrations apply'() {
        when:
        def person = Person.withTransaction {
            def p = new Person(name: 'Test Person', age: 30).save(flush: true, failOnError: true)
            new Address(
                person: p,
                streetName: 'Main St',
                city: 'Austin',
                zipCode: '78701'
            ).save(flush: true, failOnError: true)
            p
        }

        then:
        person.id
        Address.countByPerson(person) == 1
        Address.findByPerson(person).city == 'Austin'
    }

    void 'legacy person address columns migrate into address table'() {
        given: 'an isolated DB at the add-address-fields stage'
        Connection conn = DriverManager.getConnection(
                postgres.jdbcUrl,
                postgres.username,
                postgres.password
        )
        Sql sql = new Sql(conn)
        applyThroughAddressFields(sql)

        when: 'a legacy person row still has denormalized address columns'
        sql.executeInsert('''insert into person (version, name, age, street_name, city, zip_code)
                            values (0, 'Legacy Person', 42, 'Congress Ave', 'Austin', '78701')''')

        and: 'the redesign SQL from create-address-table.groovy runs'
        applyAddressRedesign(sql)

        then: 'address row matches the legacy values and person address columns are gone'
        def address = sql.firstRow('select street_name, city, zip_code from address')
        address.street_name == 'Congress Ave'
        address.city == 'Austin'
        address.zip_code == '78701'
        !columnNames(conn, 'person').contains('street_name')
        !columnNames(conn, 'person').contains('city')
        !columnNames(conn, 'person').contains('zip_code')

        cleanup:
        sql?.close()
        conn?.close()
    }

    private static void applyThroughAddressFields(Sql sql) {
        sql.execute('''
            create table person (
                id bigserial primary key,
                version bigint not null,
                name varchar(255) not null,
                age integer,
                city varchar(255),
                street_name varchar(255),
                zip_code varchar(255)
            )
        ''')
    }

    private static void applyAddressRedesign(Sql sql) {
        sql.execute('''
            create table address (
                id bigserial primary key,
                version bigint not null,
                person_id bigint not null references person(id),
                street_name varchar(255),
                city varchar(255),
                zip_code varchar(255)
            )
        ''')
        sql.execute('''
            insert into address (version, person_id, street_name, city, zip_code)
            select 0, id, street_name, city, zip_code from person
            where street_name is not null or city is not null or zip_code is not null
        ''')
        sql.execute('alter table person drop column city')
        sql.execute('alter table person drop column street_name')
        sql.execute('alter table person drop column zip_code')
    }

    private boolean tableExists(String table) {
        def conn = dataSource.connection
        try {
            return tableExists(conn, table)
        } finally {
            conn.close()
        }
    }

    private static boolean tableExists(Connection conn, String table) {
        def rs = conn.metaData.getTables(null, 'public', table, ['TABLE'] as String[])
        return rs.next()
    }

    private Set<String> columnNames(String table) {
        def conn = dataSource.connection
        try {
            return columnNames(conn, table)
        } finally {
            conn.close()
        }
    }

    private static Set<String> columnNames(Connection conn, String table) {
        def rs = conn.metaData.getColumns(null, 'public', table, null)
        def names = [] as Set
        while (rs.next()) {
            names << rs.getString('COLUMN_NAME').toLowerCase()
        }
        return names
    }

    private boolean foreignKeyExists(String fkTable, String fkColumn, String pkTable, String pkColumn) {
        def conn = dataSource.connection
        try {
            def rs = conn.metaData.getImportedKeys(null, 'public', fkTable)
            while (rs.next()) {
                if (rs.getString('FKTABLE_NAME').equalsIgnoreCase(fkTable) &&
                    rs.getString('FKCOLUMN_NAME').equalsIgnoreCase(fkColumn) &&
                    rs.getString('PKTABLE_NAME').equalsIgnoreCase(pkTable) &&
                    rs.getString('PKCOLUMN_NAME').equalsIgnoreCase(pkColumn)) {
                    return true
                }
            }
            return false
        } finally {
            conn.close()
        }
    }
}

The migration-path test builds the pre-redesign schema on an isolated Testcontainers database, seeds a legacy row, then runs the same expand-migrate-contract SQL as create-address-table.groovy.

Run unit tests:

cd complete
./gradlew test

Run integration tests (requires Docker):

./gradlew integrationTest

14 Running the application

cd complete
./gradlew bootRun

Example endpoint:

curl -s http://localhost:8080/api/persons

Migrations apply on startup via updateOnStart: true.

15 Summary

You evolved a PostgreSQL schema safely with Liquibase and the Grails Database Migration plugin:

  • Baseline changelog from GORM domains with dbCreate: none

  • Nullable constraint changes via dbm-gorm-diff

  • Column additions and table redesign with data migration SQL, preconditions, and designed rollbacks

  • Release workflow: dbm-statusdbm-update-sqldbm-update

  • Spock unit and integration tests, including a migration-path proof

Next steps: integrate migrations into CI/CD, or pair with the Docker bootBuildImage guide for containerized releases.

16 Do you need help with Grails?

Help with Apache Grails

Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.