GORM With Versioned Migrations Using goose or Atlas
Run GORM on a schema owned by goose migrations, apply and roll back versions from Go, and add a drift check so the models never outrun the migrations

AutoMigrate is fine for tests and a poor deploy step, and the usual answer is to move the schema into versioned SQL migrations. GORM does not get in the way of that. It only needs the tables to exist and to match the structs. This article sets up the arrangement that works: goose applies migrations that are embedded in the binary, GORM queries the result, and a small test fails when the two describe different schemas. It runs on PostgreSQL 18.6 and prints what each step did.
Tested with Go 1.25.6, GORM v1.31.2, goose v3.26.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64). Atlas was not run.
The general practice is in the goose article, embedded migrations and testing migrations in CI. This article is about the seam between a migration tool and an ORM.
Why two descriptions of one schema
With migrations, the schema is described twice: once as SQL in the migration files, and once as Go structs in the models. The database is the truth, the migrations are how it got there, and the structs are what the application believes. They drift in three ways:
- A field is added to a struct and the migration is forgotten. Queries fail at run time with
column does not exist. - A migration adds a
not nullcolumn with no default, and the struct has no field for it. Inserts fail with anot nullviolation. - A type changes in one place. A
bigintread into anint32works until a value is too large.
The first two are caught by one query against the database. The lab includes that check.
Embedded migrations
SQL files are compiled into the binary with embed, so a deploy ships the code and the migrations that match it. The files are plain goose format, with -- +goose Up and -- +goose Down markers:
-- +goose Up
create table customers (
id bigserial primary key,
email text not null unique,
name text not null,
plan text not null default 'free'
);
-- +goose Down
drop table customers;-- +goose Up
alter table customers add column credit int not null default 0;
-- +goose Down
alter table customers drop column credit;File names start with a zero padded number so that they sort and apply in order. A migration that has run in any shared environment is never edited. A fix is a new migration, because the history is a log and the tool records which versions ran.
The program
The program creates a clean schema, builds a goose Provider over the embedded files and the *sql.DB that GORM already holds (db.DB()), applies everything with Up, uses GORM normally, runs the drift check and rolls one version back:
package main
import (
"context"
"embed"
"fmt"
"github.com/pressly/goose/v3"
"io/fs"
"log"
"strings"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func must(err error) {
if err != nil {
log.SetFlags(0)
log.Fatal(err)
}
}
// connect recreates a schema and returns a connection whose search_path points at it.
func connect(schema string, cfg *gorm.Config) *gorm.DB {
base := "host=127.0.0.1 port=55432 user=postgres password=secret dbname=lab sslmode=disable"
admin, err := gorm.Open(postgres.Open(base), &gorm.Config{Logger: logger.Discard})
must(err)
must(admin.Exec("drop schema if exists " + schema + " cascade").Error)
must(admin.Exec("create schema " + schema).Error)
if cfg == nil {
cfg = &gorm.Config{Logger: logger.Discard}
}
db, err := gorm.Open(postgres.Open(base+" search_path="+schema), cfg)
must(err)
return db
}
//go:embed migrations/*.sql
var migrations embed.FS
// The model the application believes in.
type Customer struct {
ID uint
Email string
Name string
Plan string
Credit int
}
func main() {
db := connect("l0715", nil)
sqlDB, err := db.DB()
must(err)
// goose owns the schema; GORM never calls AutoMigrate here.
fsys, err := fs.Sub(migrations, "migrations")
must(err)
provider, err := goose.NewProvider(goose.DialectPostgres, sqlDB, fsys)
must(err)
results, err := provider.Up(context.Background())
must(err)
for _, r := range results {
fmt.Printf("applied %s (%s)\n", r.Source.Path, r.Direction)
}
// GORM works against the migrated table.
must(db.Create(&Customer{Email: "[email protected]", Name: "Ada", Plan: "free"}).Error)
var c Customer
must(db.First(&c).Error)
fmt.Println("customer:", c.Name, c.Plan, "credit:", c.Credit)
// A drift check: every field of the model must exist in the migrated schema.
m := db.Migrator()
var missing []string
for _, col := range []string{"id", "email", "name", "plan", "credit"} {
if !m.HasColumn(&Customer{}, col) {
missing = append(missing, col)
}
}
fmt.Println("columns missing from the schema:", missing)
// Add a field without a migration, and the check names it.
type Drifted struct {
ID uint
Email string
Nickname string
}
err = db.Table("customers").Select("id", "email", "nickname").Take(&Drifted{}).Error
fmt.Println("query for an unmigrated column:", strings.Split(err.Error(), "\n")[0])
// Roll the last migration back.
down, err := provider.Down(context.Background())
must(err)
fmt.Printf("rolled back %s\n", down.Source.Path)
v, err := provider.GetDBVersion(context.Background())
must(err)
fmt.Println("version now:", v)
}applied 00001_create_customers.sql (up)
applied 00002_add_credit.sql (up)
customer: Ada free credit: 0
columns missing from the schema: []
query for an unmigrated column: ERROR: column "nickname" does not exist (SQLSTATE 42703)
rolled back 00002_add_credit.sql
version now: 1Sharing the connection pool is the design choice to note. goose and GORM both want a database/sql handle, and GORM can hand over the one it opened, so the process has one pool and one set of credentials. For a service where migrations run in a separate job, give the job its own connection with a role that is allowed to alter tables, and keep the application's role limited to reads and writes.
What the output shows
The first two lines are applied 00001_create_customers.sql (up) and applied 00002_add_credit.sql (up). The Provider found both files, ran them in version order, and recorded them in its goose_db_version table. Calling Up again would do nothing, which is what makes it safe to run at every start.
GORM then created and read a customer, and the output customer: Ada free credit: 0 shows two defaults at work: plan came from the column default in SQL, and credit came from the second migration. GORM sent no value for either and the database filled them in. That is the pattern to prefer: defaults belong in the schema, so every writer gets them.
The next line, columns missing from the schema: [], is the drift check. It loops over the column names the model expects and asks db.Migrator().HasColumn whether each exists. It is a few lines and it catches the first drift category above. In a real project this goes into a test that runs after the migrations, against the CI database, and fails the build with the list of missing columns.
To show what the failure looks like without the check, the lab queries a column no migration created. PostgreSQL answers ERROR: column "nickname" does not exist (SQLSTATE 42703). That is the error you would otherwise meet in production on the first request after a deploy that shipped the struct but not the migration.
Finally, provider.Down rolled back one version: rolled back 00002_add_credit.sql, and GetDBVersion returned 1. Down steps back a single migration, which is the behaviour you want from a rollback during an incident, and it only works if every migration's Down section is correct. Test the down path in CI too, by migrating up, down and up again on an empty database.
Ordering a deploy
The order matters and it depends on the change:
- Additive change (new column, new table). Migrate first, then deploy the code that uses it. Old code ignores the new column.
- Removal (drop a column). Deploy the code that no longer uses it first, then migrate. A struct field mapped to a dropped column breaks every query.
- Rename or type change. Do it in steps across several deploys: add the new column, write to both, backfill, switch reads, stop writing the old one, drop it. The zero downtime article walks through it.
GORM adds one rule to this: Select * is what the builder emits by default, so a new column in the table appears in structs that have a matching field and is ignored otherwise. That is safe for additive changes. A model with a field for a column that does not exist yet fails every query, even First.
Atlas and GORM
Atlas takes the opposite route. It reads the GORM models, compares them with the migration directory or a live database, and writes the SQL for the difference. Atlas ships a GORM provider for this, loaded in atlas.hcl as an external schema that runs a Go program over your model package. You then review the generated SQL file, commit it, and apply it with the Atlas CLI or in a pipeline. The appeal is that the Go structs stay the source of truth and nobody writes the ALTER TABLE by hand. The cost is another tool and a loader program to maintain, and a generated migration still needs review for locks and data backfills, since a diff tool does not know your table has 80 million rows.
I did not run Atlas for this article, so the description above comes from its documentation, and the lab covers goose only. If you take the Atlas route, the checklist below still applies: review each file, never edit applied ones, and keep a drift check in CI (Atlas has migrate lint and schema diff for that).
Which to choose
| Situation | Choice |
|---|---|
| Small team, SQL comfortable, few tables | goose or golang-migrate with hand written SQL |
| Many models, frequent changes, Go first team | Atlas diffing from the GORM models |
| Need Go code in a migration (backfill with logic) | goose Go migrations |
| Schema shared with other services | SQL files, one owner |
Whichever you pick, the rule stays: one tool owns the schema, and the ORM's own migrator is reserved for tests.
Checklist
- Embed migrations in the binary so code and schema ship together.
- Apply migrations in a deploy step, not from every instance at start.
- Never edit an applied migration. Add a new one.
- Keep a drift test that lists model columns missing from the migrated schema.
- Test
Down, and run migrations up, down and up in CI. - Put defaults in the schema, so every writer gets them.
The goose API is in its repository documentation, and the GORM pairing for Atlas is described in the Atlas GORM guide.


