Backend

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.

models.Models()
the model registry
grit migrate
create tables + add columns
grit seed
fills tables (idempotent)
Model registry
Schema — migrate
Data — seed

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:

Terminal
$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:

output
================================================================
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.

apps/api/internal/models/user.go
// 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, 0
for _, 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 []string
for _, 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:

apps/api/cmd/migrate/main.go
package main
import (
"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:

Terminal
$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:

apps/api/internal/database/migrate.go
// DropAll drops all tables in the database.
// Used by the migrate --fresh command.
func DropAll(db *gorm.DB) error {
var tables []string
if 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.

Terminal
$grit generate resource Category

This adds &Category{} to the Models() list:

apps/api/internal/models/user.go
func Models() []interface{} {
return []interface{}{
&User{},
&Upload{},
&Category{},
// grit:models
}
}

After generating the resource, run migrations to create the new table:

Terminal
$grit migrate

The output confirms that only the new table was created:

output
DATABASE MIGRATION — 3 model(s) registered
+ created models.Category
Migration 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.

Correct ordering
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:

1

Start infrastructure

Terminal
$docker compose up -d
2

Run migrations

Terminal
$grit migrate
3

Seed the database (optional)

Terminal
$grit seed
4

Start the server

Terminal
$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.