| name | go-gorm |
| description | Use this skill whenever Go code interacts with a database via GORM — including model definitions, querying, inserts/updates/deletes, transactions, preloading relations, joins, migrations, or repository implementations. Triggers include any mention of `gorm.DB`, `gorm:"..."` tags, `db.Find`, `db.Where`, `db.Create`, `db.Preload`, `db.Joins`, `db.Exec`, `db.Raw`, "use GORM", "load related records", "N+1", "master/detail", or any Go file that imports `gorm.io/gorm`. Apply this skill even when the user doesn't explicitly ask for GORM guidance — if they're touching GORM code, these rules apply. |
Go + GORM Conventions
Encodes the preferred way to write database access code with GORM. The two non-negotiable rules:
- Use the GORM API. Avoid
db.Exec and db.Raw.
- Never write N+1 query patterns for master/detail loads.
Everything else flows from these.
Rule 1 — Prefer the GORM API over raw SQL
The GORM builder API is the default for all queries. db.Exec and db.Raw are an escape hatch, used only when the builder genuinely cannot express what's needed.
Use the builder for:
- All CRUD:
Create, First, Find, Save, Updates, Delete
- Conditions:
Where, Or, Not, In, Between
- Joins:
Joins("LEFT JOIN ...") — note Joins accepts a SQL fragment but is still GORM API, not raw exec
- Aggregates:
Select("COUNT(*)").Count(...), Group, Having
- Subqueries: pass a
*gorm.DB into Where(...) or Table((?), subQuery)
- Upserts:
Clauses(clause.OnConflict{...}).Create(...)
- Bulk inserts/updates:
CreateInBatches, Updates with a struct or map
- Transactions:
db.Transaction(func(tx *gorm.DB) error { ... })
Raw / Exec are acceptable only when:
- Using a database-specific feature GORM does not expose (e.g. PostgreSQL
RETURNING on a complex CTE, window functions in a SELECT projection, LATERAL JOIN, recursive CTEs).
- Calling a stored procedure or DB function with no GORM equivalent.
- Bulk operations where the builder produces demonstrably worse SQL and benchmarks show it matters.
When raw SQL is used, the call site must include a comment explaining why the builder is insufficient. Without that justification, reviewers should ask for a refactor.
var users []User
err := db.Where("status = ? AND created_at > ?", "active", since).Find(&users).Error
err := db.Raw("SELECT * FROM users WHERE status = ? AND created_at > ?", "active", since).Scan(&users).Error
err := db.Raw(`
WITH RECURSIVE tree AS (
SELECT id, parent_id, name FROM orgs WHERE id = ?
UNION ALL
SELECT o.id, o.parent_id, o.name FROM orgs o JOIN tree t ON o.parent_id = t.id
) SELECT * FROM tree`, rootID).Scan(&result).Error
Rule 2 — No N+1 queries on master/detail loads
The default pattern for loading a list of records with their related details is two queries: one for masters, one for details (filtered with IN), merged in Go.
GORM's Preload does this for you when it fits, but for hand-rolled cases or when the relation isn't declared, write it explicitly.
Pattern A — Use Preload when the relation is declared
type Order struct {
ID string `gorm:"primaryKey"`
Items []OrderItem `gorm:"foreignKey:OrderID"`
}
type OrderItem struct {
ID string `gorm:"primaryKey"`
OrderID string `gorm:"column:order_id;index"`
SKU string
}
var orders []Order
err := db.Preload("Items").Where("user_id = ?", userID).Find(&orders).Error
Pattern B — Two-query manual merge when Preload doesn't fit
Use this when the detail rows are filtered, projected differently, or come from a join that Preload can't express.
var masters []MyMaster
if err := db.Where(...).Find(&masters).Error; err != nil { return err }
if len(masters) == 0 { return nil }
ids := make([]string, len(masters))
for i, m := range masters { ids[i] = m.Id }
var details []MyDetail
if err := db.Where("master_id IN ?", ids).Find(&details).Error; err != nil { return err }
byMaster := make(map[string][]MyDetail, len(masters))
for _, d := range details {
byMaster[d.MasterID] = append(byMaster[d.MasterID], d)
}
result := make([]MyMasterWithDetails, len(masters))
for i, m := range masters {
result[i] = MyMasterWithDetails{MyMaster: m, MyDetails: byMaster[m.Id]}
}
Two queries, regardless of how many masters. The grouping step is O(n+m) in Go memory — cheap.
Forbidden pattern — looping queries
for _, m := range masters {
var details []MyDetail
db.Where("master_id = ?", m.Id).Find(&details)
...
}
If you see this in a review, reject it. There is no scenario in which it's the right choice for a list load.
Master/detail composition with embedded structs
When the API returns a master with its details aggregated, use struct embedding so the master's fields stay queryable as a flat table while the wrapper carries the details:
type MyMaster struct {
Id string `gorm:"primaryKey"`
Name string `gorm:"column:name"`
}
type MyDetail struct {
Id string `gorm:"primaryKey"`
MasterID string `gorm:"column:master_id;index"`
Name string `gorm:"column:name"`
}
type MyMasterWithDetails struct {
MyMaster
MyDetails []MyDetail `gorm:"foreignKey:MasterID;references:Id"`
}
Key tag elements:
foreignKey:MasterID — the field on MyDetail that holds the FK.
references:Id — the field on the embedded MyMaster the FK points to. Required when the PK field isn't named ID (GORM's default assumption).
- Indexing: always tag the FK column with
index (gorm:"column:master_id;index") — the IN (?) query in Pattern B relies on it.
Reading back with Preload
var rows []MyMasterWithDetails
err := db.Model(&MyMaster{}).
Preload("MyDetails").
Where("name LIKE ?", "%foo%").
Find(&rows).Error
Note db.Model(&MyMaster{}) — because MyMasterWithDetails embeds MyMaster, GORM needs the explicit model to resolve the table. Without it, GORM may try to infer a table from the wrapper type name.
Why embedding over a separate field
type MyMasterWithDetails struct {
MyMaster
MyDetails []MyDetail `gorm:"foreignKey:MasterID;references:Id"`
}
type MyMasterWithDetails struct {
Master MyMaster `gorm:"embedded"`
MyDetails []MyDetail `gorm:"foreignKey:MasterID;references:Master.Id"`
}
The embedded form is the idiomatic Go shape and aligns with how the DB row maps onto the struct.
Other GORM conventions
Context everywhere
Every query takes a context. db.WithContext(ctx) at the entry of every repository method. Cancellation propagates to the driver.
func (r *OrderRepo) Get(ctx context.Context, id string) (*Order, error) {
var o Order
if err := r.db.WithContext(ctx).First(&o, "id = ?", id).Error; err != nil {
return nil, err
}
return &o, nil
}
Error handling
gorm.ErrRecordNotFound is a real error, not a "soft" miss. Map it at the repository boundary to a domain NotFoundError. Don't let it bubble up untyped into the service layer.
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, domain.ErrOrderNotFound
}
Transactions
Use db.Transaction(func(tx *gorm.DB) error { ... }) — return non-nil to roll back. Never call Begin/Commit/Rollback manually unless you genuinely need control over the txn boundary across function calls (rare).
Pass tx (not the outer db) into every call inside the closure. Mixing them is a classic source of "transaction did nothing" bugs.
Updates: Save vs Updates
Save writes all fields, including zero values. Use only when you genuinely mean "replace the row."
Updates with a struct skips zero-valued fields. Surprising but documented.
Updates with a map[string]any writes exactly what you pass. Prefer the map form for partial updates — it's explicit about which columns change.
db.Model(&order).Updates(map[string]any{
"status": "shipped",
"shipped_at": time.Now(),
})
Soft deletes
Default GORM behavior with gorm.DeletedAt is fine. Document on each model whether soft-delete is enabled; surprising it later breaks queries.
Select for projection
When you only need a few columns, use Select — don't load the whole row.
var names []string
db.Model(&User{}).Where("active").Pluck("name", &names)
Reusable query fragments with Scopes
When the same condition appears in more than one query (active, not deleted, belongs to tenant, etc.), extract it as a scope rather than copy-pasting Where calls.
func Active(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "active")
}
func ForTenant(tenantID string) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("tenant_id = ?", tenantID)
}
}
var users []User
db.WithContext(ctx).
Scopes(Active, ForTenant(tid)).
Find(&users)
Scopes compose, are testable in isolation, and keep repository methods readable. Put them next to the model they query.
Hooks (BeforeCreate, AfterFind, …)
GORM fires hooks on every operation that touches a model. They run inside the same transaction as the query, which makes them tempting — and also a frequent source of bugs:
- Hidden side effects — a
BeforeCreate that mutates a field surprises every caller.
- Performance traps — an
AfterFind that runs a subquery turns a list load into N+1.
- Test pain — hooks fire in tests too, often pulling in dependencies the test didn't intend to exercise.
Default position: prefer explicit code over hooks. Set CreatedBy, generate IDs, normalize emails, etc., in the repository or service layer where the caller can see it.
Hooks are acceptable for:
- Setting
ID from a uuid.NewV7() when the field is empty, in BeforeCreate.
- Validating invariants that must hold for every write (a last line of defense, not the primary validation).
When a model uses hooks, document it at the top of the struct so callers know without grepping:
type Order struct { ... }
Connection pool configuration
GORM wraps database/sql. The pool defaults are wrong for production — set them explicitly at startup:
gormDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ ... })
sqlDB, err := gormDB.DB()
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(25)
sqlDB.SetConnMaxLifetime(time.Hour)
sqlDB.SetConnMaxIdleTime(30 * time.Minute)
Starting points, not magic numbers — tune from load tests. Key principles:
MaxOpenConns must be well below the DB's max_connections, divided across all replicas of the service.
ConnMaxLifetime is critical behind proxies (PgBouncer, RDS Proxy) and managed DBs that rotate connections.
MaxIdleConns equal to MaxOpenConns avoids the open/close churn under bursty load.
Logging and slow query detection
Wire a logger with a slow-query threshold at startup. This is the cheapest way to catch the N+1s the rest of this skill warns about.
import gormlogger "gorm.io/gorm/logger"
gormDB, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormlogger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
gormlogger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: gormlogger.Warn,
IgnoreRecordNotFoundError: true,
ParameterizedQueries: true,
Colorful: false,
},
),
})
In production, route the logger to structured logs (zap/zerolog/slog adapter) and ship slow-query warnings to the same place metrics go. Don't enable Info level in production — it logs every query.
Migrations
AutoMigrate is dev-only. It will silently skip drops and column type changes, leaving the schema subtly diverged from the models. In production, it's worse: schema changes ship without review and without rollback.
Use a dedicated migration tool — golang-migrate, goose, or atlas — with versioned SQL files committed to the repo. Migrations run as a separate step in CI/CD (not on app startup), so a failed migration doesn't leave half the fleet on an inconsistent schema.
migrations/
├── 0001_create_orders.up.sql
├── 0001_create_orders.down.sql
├── 0002_add_status_index.up.sql
└── 0002_add_status_index.down.sql
Rules:
- Every
up has a down. Test rollback in CI for at least the most recent migration.
- Migrations are forward-only in production; the
down is for local dev and recovery.
- Never edit a migration that has shipped to any environment — write a new one.
AutoMigrate may appear in tests and local dev tooling. It must not appear in main.go of a deployed service.
Anti-patterns to reject
db.Exec / db.Raw without a comment explaining why the builder can't do it.
- Looping
Find/First calls inside a range (N+1).
- String-concatenating user input into SQL — even with
Raw, use placeholders (?).
- Shared
*gorm.DB mutated by db.Where(...) then reused — chained calls return new sessions; reuse the original db, not the chained variant. Use db.Session(&gorm.Session{NewDB: true}) if you need to be defensive.
Save for partial updates — use Updates with a map.
- Missing
WithContext(ctx) on repository methods.
- Foreign-key columns without an
index tag.
- Calling
Begin/Commit manually when db.Transaction(...) would do.
- Returning
gorm.ErrRecordNotFound out of a repository unmapped.
- Copy-pasting the same
Where clause across repositories instead of extracting a Scope.
- Hooks with hidden side effects, or hooks that issue extra queries on
AfterFind.
- Default connection pool settings in production.
AutoMigrate running on app startup in any deployed environment.