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/andcomplete/ -
Docker - required for integration tests (Testcontainers PostgreSQL)
-
PostgreSQL 16 on
localhost:5432- required for./gradlew bootRunin bothinitial/andcomplete/(default databasedevDb; seegrails-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:
implementation "org.apache.grails:grails-data-hibernate5-dbmigration"
Disable Hibernate auto-DDL and apply migrations on startup in 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:
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:
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)
}
}
}
}
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:
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
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:
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:
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:
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:
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')
}
}
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-checksumsrecalculates 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:
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()
}
}
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:
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-status→dbm-update-sql→dbm-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.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.