Migrations
Grit wraps GORM's AutoMigrate with a diff-logging runner: it creates any missing tables and adds new columns to tables that already exist, then prints exactly what changed — created, altered, or unchanged. Migrations run as a separate command, never on server startup, so you stay in control of when your schema changes.
The lifecycle at a glance
Migrations create the tables; seeders fill them with rows. Both are explicit commands you run — never on server startup — so you always control when the schema and data change. The usual first-run order is migrate, then seed, then serve.
This page covers the left two boxes — the model registry and the migrate command. For the third box (filling tables with data, faker, and relationships) see Seeders.
Running Migrations
Before starting the API server for the first time (or after adding new models), run the migrate command:
$grit migrate
The migrate command connects to your database and runs AutoMigrate for every registered model — creating missing tables and adding any new columns to existing ones. It snapshots the columns before and after, so the log tells you exactly what changed:
================================================================DATABASE MIGRATION — 8 model(s) registered================================================================+ created models.Category~ models.User — added 2 column(s): job_title, bio----------------------------------------------------------------Migration done — 1 table(s) created, 1 altered (+2 column(s)), 6 unchanged.================================================================
How It Works
The migration system is built on two functions in internal/models/user.go: a Models() registry and a Migrate() runner.
// Models returns the ordered list of all models for migration.// Models with no foreign key dependencies come first.func Models() []interface{} {return []interface{}{&User{},&Upload{},// grit:models}}// Migrate runs AutoMigrate for every registered model. For tables that// already exist, GORM ALTERs them to add missing columns — we snapshot// the column set before and after so the log surfaces exactly what changed.func Migrate(db *gorm.DB) error {models := Models()created, altered, columnsAdded, unchanged := 0, 0, 0, 0for _, model := range models {existed := db.Migrator().HasTable(model)// Snapshot columns before, so we can diff what AutoMigrate adds.before := map[string]bool{}if existed {cols, _ := db.Migrator().ColumnTypes(model)for _, c := range cols {before[c.Name()] = true}}if err := db.AutoMigrate(model); err != nil {return fmt.Errorf("migrating %T: %w", model, err)}if !existed {log.Printf(" + created %T", model)created++continue}// Diff columns to surface anything AutoMigrate added.after, _ := db.Migrator().ColumnTypes(model)var added []stringfor _, c := range after {if !before[c.Name()] {added = append(added, c.Name())}}if len(added) == 0 {unchanged++continue}log.Printf(" ~ %T — added %d column(s): %s", model, len(added), strings.Join(added, ", "))altered++columnsAdded += len(added)}log.Printf("Migration done — %d created, %d altered (+%d column(s)), %d unchanged.",created, altered, columnsAdded, unchanged)return nil}
Every model is passed through AutoMigrate. A brand-new table is created; an existing table is altered to add any new columns your struct gained (GORM never drops columns or changes existing types). By snapshotting the columns before and after, the runner can print a precise created / altered / unchanged summary instead of migrating silently.
The Migrate Entrypoint
The migrate command lives at cmd/migrate/main.go. It loads your config, connects to the database, and runs the migration:
package mainimport ("flag""fmt""log""os""myapp/apps/api/internal/config""myapp/apps/api/internal/database""myapp/apps/api/internal/models")func main() {fresh := flag.Bool("fresh", false, "Drop all tables before migrating")flag.Parse()cfg, err := config.Load()if err != nil {log.Fatalf("Failed to load config: %v", err)}db, err := database.Connect(cfg.DatabaseURL)if err != nil {log.Fatalf("Failed to connect to database: %v", err)}if *fresh {fmt.Println("Dropping all tables...")if err := database.DropAll(db); err != nil {log.Fatalf("Failed to drop tables: %v", err)}fmt.Println("All tables dropped.")}fmt.Println("Running migrations...")if err := models.Migrate(db); err != nil {log.Fatalf("Migration failed: %v", err)}fmt.Println("Migrations completed successfully.")os.Exit(0)}
Fresh Migrations
When you need to start from scratch — during development or testing — use the --fresh flag. This drops all tables before re-running migrations:
$grit migrate --fresh
Warning: The --fresh flag permanently deletes all data. Never use it in production.
Fresh migrations are useful when:
- ✓You changed column types or removed fields from a model
- ✓You need to reset your local development database
- ✓You want to re-seed with fresh test data
The DropAll helper uses raw SQL to drop all public tables:
// DropAll drops all tables in the database.// Used by the migrate --fresh command.func DropAll(db *gorm.DB) error {var tables []stringif err := db.Raw("SELECT tablename FROM pg_tables WHERE schemaname = 'public'").Scan(&tables).Error; err != nil {return fmt.Errorf("failed to list tables: %w", err)}if len(tables) == 0 {return nil}for _, table := range tables {if err := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %q CASCADE", table)).Error; err != nil {return fmt.Errorf("failed to drop table %s: %w", table, err)}}return nil}
Adding New Models
When you generate a new resource with grit generate resource, the model is automatically registered in the Models() function via the // grit:models marker.
$grit generate resource Category
This adds &Category{} to the Models() list:
func Models() []interface{} {return []interface{}{&User{},&Upload{},&Category{},// grit:models}}
After generating the resource, run migrations to create the new table:
$grit migrate
The output confirms that only the new table was created:
DATABASE MIGRATION — 3 model(s) registered+ created models.CategoryMigration done — 1 created, 0 altered (+0 column(s)), 2 unchanged.
Foreign Key Ordering
When models have foreign key relationships, the order in Models() matters. Parent tables must come before child tables so foreign key constraints can be created.
func Models() []interface{} {return []interface{}{&User{}, // ← No dependencies (parent)&Upload{}, // ← Depends on User (has UserID FK)&Category{}, // ← No dependencies&Product{}, // ← Depends on Category (has CategoryID FK)&Order{}, // ← Depends on User (has UserID FK)// grit:models}}
The grit generate resource command always appends new models at the end (before the marker). If a new model depends on another table, make sure the parent model is listed first. You can safely reorder the entries in Models() — just keep the // grit:models marker as the last line.
Tip: If you see a "foreign key constraint" error during migration, check that the parent model appears before the child model in Models().
Typical Workflow
Here's the recommended workflow when starting or extending a Grit project:
Start infrastructure
$docker compose up -d
Run migrations
$grit migrate
Seed the database (optional)
$grit seed
Start the server
$grit start server
What GORM AutoMigrate Does
Under the hood, Grit's Migrate() function calls GORM's AutoMigrate for each missing table. AutoMigrate will:
- ✓Create the table with columns matching your struct fields
- ✓Add indexes and constraints from struct tags (index, uniqueIndex)
- ✓Create foreign key constraints from relationship fields
- ✓Never delete existing columns or tables (safe by design)
- ✓Never change existing column types automatically
Note: AutoMigrate is great for development and simple schemas. For production systems that need column renaming, type changes, or data migrations, consider using a dedicated migration tool like golang-migrate or goose alongside GORM. Use --fresh during development if you need to change column types.
