GORM Migrations AutoMigrate and Its Limits in Production
What GORM AutoMigrate did to a live PostgreSQL table: it added columns and an index, shrank a varchar, changed a type, and never dropped or renamed a thing

AutoMigrate is one line, and it turns a set of structs into a database schema. It is the feature that makes GORM feel quick on day one, and it is the feature that causes trouble in month six, when the schema in production has drifted from the models in ways nobody meant. The way to use it well is to know exactly what it does and does not do. This article migrates one table through two versions on PostgreSQL 18.6, prints the real columns and indexes after each step, and reads the result.
Tested with Go 1.25.6, GORM v1.31.2, gorm.io/driver/postgres v1.6.3, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It continues from the GORM tutorial and models. The production alternative is versioned migrations with goose or Atlas, and the general migration practice is in zero downtime migrations and the database cornerstone.
The lab
V1 is the first version of an items table. V2 changes it in five ways, and both map to the same table through TableName(). After each step, a helper prints the columns from information_schema and the indexes from pg_indexes.
package main
import (
"fmt"
"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
}
// Version one of the table.
type V1 struct {
ID uint
Name string `gorm:"size:50"`
Score int
Note string
}
func (V1) TableName() string { return "items" }
// Version two: a new column, a new index, a renamed Go field, a removed field, a changed type.
type V2 struct {
ID uint
Name string `gorm:"size:20;index"`
Points int // renamed from Score
Score string // same column name as before, new type
Category string `gorm:"default:general"`
}
func (V2) TableName() string { return "items" }
func dump(db *gorm.DB, label string) {
var cols []string
db.Raw(`select column_name || ' ' || data_type || coalesce('(' || character_maximum_length || ')', '') ||
case when is_nullable = 'NO' then ' not null' else '' end ||
coalesce(' default ' || column_default, '')
from information_schema.columns where table_schema = 'l0714' and table_name = 'items' order by ordinal_position`).Scan(&cols)
var idx []string
db.Raw(`select indexname from pg_indexes where schemaname = 'l0714' and tablename = 'items' order by 1`).Scan(&idx)
fmt.Println("==", label)
for _, c := range cols {
fmt.Println(" ", c)
}
fmt.Println(" indexes:", strings.Join(idx, ", "))
}
func main() {
db := connect("l0714", nil)
must(db.AutoMigrate(&V1{}))
db.Create(&[]V1{{Name: "a", Score: 7, Note: "x"}, {Name: "b", Score: 9}})
dump(db, "after V1")
err := db.AutoMigrate(&V2{})
fmt.Println("AutoMigrate V2 error:", err)
dump(db, "after V2")
var rows []map[string]any
db.Table("items").Order("id").Find(&rows)
for _, r := range rows {
fmt.Printf("row: name=%v score=%v note=%v category=%v\n", r["name"], r["score"], r["note"], r["category"])
}
// Run it again: it is idempotent.
fmt.Println("second run error:", db.AutoMigrate(&V2{}))
// A NOT NULL column with no default cannot be added to a table that has rows.
type V3 struct {
ID uint
Email string `gorm:"not null"`
}
err = db.Table("items").AutoMigrate(&V3{})
fmt.Println("add not null column:", err)
// The migrator can do the explicit things.
m := db.Migrator()
fmt.Println("has note:", m.HasColumn(&V1{}, "note"))
must(m.DropColumn(&V1{}, "note"))
fmt.Println("has note after DropColumn:", m.HasColumn(&V1{}, "note"))
must(m.RenameColumn(&V2{}, "points", "score_points"))
dump(db, "after explicit DropColumn and RenameColumn")
}== after V1
id bigint not null default nextval('items_id_seq'::regclass)
name character varying(50)
score bigint
note text
indexes: items_pkey
AutoMigrate V2 error: <nil>
== after V2
id bigint not null default nextval('items_id_seq'::regclass)
name character varying(20)
score text
note text
points bigint
category text default 'general'::text
indexes: idx_items_name, items_pkey
row: name=a score=7 note=x category=general
row: name=b score=9 note= category=general
second run error: <nil>
add not null column: ERROR: column "email" of relation "items" contains null values (SQLSTATE 23502)
has note: true
has note after DropColumn: false
== after explicit DropColumn and RenameColumn
id bigint not null default nextval('items_id_seq'::regclass)
name character varying(20)
score text
score_points bigint
category text default 'general'::text
indexes: idx_items_name, items_pkeyWhat it did
It created the table. After V1, the table had id bigint with a sequence, name varchar(50), score bigint, note text, and only the primary key index.
It added what was missing. After V2, there is a new column category text default 'general'. The two existing rows show category=general in the output: PostgreSQL filled the default into the rows when the column was added. A new index idx_items_name appeared on name because the tag asked for it. Adding columns and indexes is what AutoMigrate does best.
Be careful with the index. AutoMigrate creates it with a plain CREATE INDEX, which takes a lock that blocks writes to the table while it builds. On a table with millions of rows that is a visible outage. CREATE INDEX CONCURRENTLY avoids that and cannot run in a transaction block, so it needs a hand written migration.
It altered types in place. Two surprises in the V2 listing:
namewent fromcharacter varying(50)tocharacter varying(20). The tag changed fromsize:50tosize:20andAutoMigrateissued anALTER COLUMN ... TYPE varchar(20). With rows of 20 characters or fewer it succeeds. With a longer value PostgreSQL refuses the statement, and the migration fails halfway through a deploy. Nothing asked for confirmation.scorewent frombiginttotext, and the rows still readscore=7andscore=9. PostgreSQL converted them. A change in the opposite direction (text to integer) needs aUSINGclause to convert, andAutoMigratedoes not know how to write one, so it would error on rows whose text does not parse. The behaviour depends on the data in the table you run it against, which is exactly the situation in which you want a reviewed script.
It never dropped or renamed anything. V2 removed the Note field and renamed Score to Points. The listing after migration still has note text, and the old score is there beside a new points bigint column. The second row of data shows note= empty only because it was never set. The first row still has note=x, now invisible to the application.
That is a deliberate design: GORM will not destroy data in a migration it generates. It means a rename is a drop plus an add as far as AutoMigrate is concerned, with the old values left behind and no copy. In production that leaves unused columns that accumulate, and models that quietly read an empty new column while the data sits in the old one.
It is idempotent. The second run printed second run error: <nil> and changed nothing. That makes AutoMigrate safe to call twice, which is the property people rely on when they put it in main().
It cannot add a required column to a table with data. Adding Email string tagged not null to the table that already had rows failed: ERROR: column "email" of relation "items" contains null values (SQLSTATE 23502). The right migration adds the column nullable or with a default, backfills in batches, then adds the constraint. AutoMigrate has no concept of that sequence.
The explicit migrator
When you need a specific operation, db.Migrator() has methods for each: HasColumn, DropColumn, RenameColumn, AlterColumn, CreateIndex, DropIndex, HasTable, RenameTable, CreateConstraint. The lab used DropColumn to remove note and RenameColumn to turn points into score_points, and the final listing shows both done. These are blunt tools: they run immediately, in the current connection, with no record that they ran and no way to undo them. They are fine in a one off script. They are not a migration history.
A migration history needs three things AutoMigrate does not provide: an ordered list of changes, a record of which ones ran, and the ability to review the SQL before it runs. That is what goose and golang-migrate do.
When AutoMigrate is the right tool
- Local development and tests. A throwaway database built fresh from the models, every time. This is what it is for.
- Prototypes and internal tools where losing the database is acceptable.
- Generating a first schema that you then export and turn into the initial real migration.
- Embedded or single user databases (SQLite in an app) where there is exactly one writer and a small table.
When it is not
- Any shared database with data you need. The in place type changes and the orphaned columns above are the reason.
- Multiple instances starting together. Two processes running
AutoMigrateat once race onALTER TABLE, and one can fail while the other succeeds. Migrate in a separate step that runs once. - Anything that needs a lock free change. Concurrent index builds,
NOT VALIDconstraints and batched backfills are outside what it generates. - A team that reviews schema changes. A diff of Go structs is not a diff of SQL, and reviewers need to see the statements.
A workable path
- Keep
AutoMigratein the test setup, where it builds the schema in milliseconds. - In production, write migrations as SQL files with goose, golang-migrate or Atlas, and run them as a deploy step.
- Let Atlas or
pg_dump --schema-onlydiff the models against the migrated database in CI, so drift between what the structs say and what the migrations built fails the build. The combination of GORM models with Atlas to compute the migration is a documented pairing, and it keeps the structs as the source of truth without letting a deploy rewrite the schema by itself.
Checklist
- Expect
AutoMigrateto add tables, columns and indexes, and to alter sizes and types in place. - Expect it never to drop or rename, and to leave old columns and their data behind.
- Do not run it from
mainon a shared or production database. - Write production changes as reviewed SQL migrations with a history.
- Use
CREATE INDEX CONCURRENTLYand a staged backfill for large tables.
The behaviour is documented in the GORM migration guide, and the cost of index creation in the PostgreSQL CREATE INDEX reference.


