A GORM Cheat Sheet for Everyday Queries and Associations
A GORM cheat sheet for PostgreSQL: create, query, update, delete, associations, transactions, upserts and errors, each line checked by a program that ran

A cheat sheet is only useful if every line is right, so each call below is exercised by one program against PostgreSQL 18.6 and its output is shown. The program uses a team with members, members with skills (many to many), soft delete and a unique email. Read it as a reference: scan the tables, and use the notes where a call has a catch.
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)
The concepts are explained in the GORM tutorial, models, associations and transactions.
The program
package main
import (
"context"
"errors"
"fmt"
"gorm.io/gorm/clause"
"log"
"time"
"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
}
type Team struct {
ID uint
Name string `gorm:"uniqueIndex;size:80"`
Members []Member
}
type Member struct {
ID uint
TeamID uint
Team Team
Email string `gorm:"uniqueIndex"`
Age int
Skills []Skill `gorm:"many2many:member_skills"`
CreatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Skill struct {
ID uint
Name string `gorm:"uniqueIndex"`
}
func main() {
db := connect("l0735", &gorm.Config{Logger: logger.Discard, TranslateError: true})
ctx := context.Background()
must(db.AutoMigrate(&Team{}, &Member{}, &Skill{}))
// create
team := Team{Name: "core"}
db.WithContext(ctx).Create(&team)
db.Create(&[]Member{{TeamID: team.ID, Email: "[email protected]", Age: 30}, {TeamID: team.ID, Email: "[email protected]", Age: 41}, {TeamID: team.ID, Email: "[email protected]", Age: 25}})
fmt.Println("create: team id", team.ID)
// read
var m Member
db.First(&m, "email = ?", "[email protected]")
var adults []Member
db.Where("age >= ?", 30).Order("age desc").Limit(5).Find(&adults)
var n int64
db.Model(&Member{}).Where("age < ?", 40).Count(&n)
var emails []string
db.Model(&Member{}).Order("email").Pluck("email", &emails)
fmt.Println("read:", m.Email, len(adults), n, emails)
// structs: zero values are skipped, maps are not
db.Where(&Member{Age: 0, Email: "[email protected]"}).Find(&adults)
fmt.Println("struct condition ignores Age 0:", len(adults))
db.Where(map[string]any{"age": 0}).Find(&adults)
fmt.Println("map condition keeps age 0:", len(adults))
// update
db.Model(&m).Update("age", 42)
db.Model(&m).Updates(Member{Age: 0, Email: "[email protected]"})
db.Model(&m).Updates(map[string]any{"age": 0})
db.First(&m, m.ID)
fmt.Println("update: struct skipped Age 0, map wrote it:", m.Email, m.Age)
res := db.Model(&Member{}).Where("age > ?", 0).Update("age", gorm.Expr("age + ?", 1))
fmt.Println("expr update rows:", res.RowsAffected)
// associations
go1, rust := Skill{Name: "go"}, Skill{Name: "rust"}
db.Create(&[]*Skill{&go1, &rust})
db.Model(&m).Association("Skills").Append(&go1, &rust)
var full Team
db.Preload("Members.Skills").First(&full, team.ID)
for _, mm := range full.Members {
if len(mm.Skills) > 0 {
fmt.Println("preload nested:", mm.Email, len(mm.Skills), "skills")
}
}
var withTeam []Member
db.Joins("Team").Where("members.age > ?", 20).Find(&withTeam)
fmt.Println("joins:", len(withTeam), withTeam[0].Team.Name)
// upsert and ignore
r := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "name"}}, DoUpdates: clause.AssignmentColumns([]string{"name"})}).Create(&Team{Name: "core"})
fmt.Println("upsert rows:", r.RowsAffected, r.Error)
r = db.Clauses(clause.OnConflict{DoNothing: true}).Create(&Team{Name: "core"})
fmt.Println("do nothing rows:", r.RowsAffected)
// transaction
err := db.Transaction(func(tx *gorm.DB) error {
tx.Create(&Skill{Name: "sql"})
return errors.New("abort")
})
var skills int64
db.Model(&Skill{}).Count(&skills)
fmt.Println("transaction:", err, "skills:", skills)
// soft delete
db.Delete(&Member{}, m.ID)
var visible, all int64
db.Model(&Member{}).Count(&visible)
db.Unscoped().Model(&Member{}).Count(&all)
fmt.Println("soft delete:", visible, all)
err = db.Unscoped().Delete(&Member{}, m.ID).Error
fmt.Println("hard delete with join rows:", errors.Is(err, gorm.ErrForeignKeyViolated))
db.Unscoped().Model(&m).Association("Skills").Clear()
db.Unscoped().Delete(&Member{}, m.ID)
db.Unscoped().Model(&Member{}).Count(&all)
fmt.Println("hard delete after clearing the links:", all)
// errors
fmt.Println("not found:", errors.Is(db.First(&Member{}, 9999).Error, gorm.ErrRecordNotFound))
fmt.Println("duplicate:", errors.Is(db.Create(&Team{Name: "core", ID: 0}).Error, gorm.ErrDuplicatedKey))
// raw
var out []struct {
Team string
Count int
}
db.Raw("select t.name as team, count(m.id) as count from teams t left join members m on m.team_id = t.id and m.deleted_at is null group by t.name").Scan(&out)
fmt.Println("raw:", out)
// dry run
sql := db.Session(&gorm.Session{DryRun: true}).Where("age > ?", 5).Limit(3).Find(&[]Member{}).Statement.SQL.String()
fmt.Println("dry run:", sql)
}create: team id 1
read: [email protected] 2 2 [[email protected] [email protected] [email protected]]
struct condition ignores Age 0: 1
map condition keeps age 0: 0
update: struct skipped Age 0, map wrote it: [email protected] 0
expr update rows: 2
preload nested: [email protected] 2 skills
joins: 2 core
upsert rows: 1 <nil>
do nothing rows: 0
transaction: abort skills: 2
soft delete: 2 3
hard delete with join rows: true
hard delete after clearing the links: 2
not found: true
duplicate: true
raw: [{core 2}]
dry run: SELECT * FROM "members" WHERE age > $1 AND "members"."deleted_at" IS NULL LIMIT $2Connect and configure
| Need | Call |
|---|---|
| Open | gorm.Open(postgres.Open(dsn), &gorm.Config{...}) |
| Map driver errors | TranslateError: true |
| Silence logs | Logger: logger.Discard |
| Skip the implicit transaction | SkipDefaultTransaction: true |
| Pool | sqlDB, _ := db.DB() then SetMaxOpenConns, SetMaxIdleConns |
| Always pass a context | db.WithContext(ctx) |
| Preview SQL | db.Session(&gorm.Session{DryRun: true})...Statement.SQL.String() |
| Print one statement | db.Debug() |
Create
| Need | Call |
|---|---|
| One row | db.Create(&row) and read row.ID |
| Many rows | db.Create(&slice) (mind 65535 parameters) |
| Large sets | db.CreateInBatches(&slice, 1000) |
| Skip a field | db.Omit("Author").Create(&row) |
| Only some fields | db.Select("Name", "Age").Create(&row) |
| Find or create | db.Where(Tag{Name: n}).FirstOrCreate(&tag) |
| Insert if absent | db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row) |
| Upsert | OnConflict{Columns: ..., DoUpdates: clause.AssignmentColumns(cols)} |
In the program, the upsert on the team name reported upsert rows: 1 <nil>, and DoNothing for the same name reported do nothing rows: 0. RowsAffected tells you whether a row was written.
Read
| Need | Call |
|---|---|
| By primary key | db.First(&m, 42) |
| One row, no ordering | db.Take(&m) |
| Newest | db.Last(&m) |
| With a condition | db.First(&m, "email = ?", e) |
| Many | db.Where("age >= ?", 30).Order("age desc").Limit(5).Find(&ms) |
| Count | db.Model(&Member{}).Where(...).Count(&n) |
| One column | db.Model(&Member{}).Pluck("email", &emails) |
| Selected columns | db.Select("id", "email").Find(&ms) |
| IN | db.Where("id IN ?", ids).Find(&ms) |
| Paging | db.Order("id").Offset(40).Limit(20) or keyset with id > ? |
| Locking | db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, id) |
The program printed read: [email protected] 2 2 [[email protected] [email protected] [email protected]]: the member, two adults, two under 40, and the sorted emails.
Struct conditions skip zero values
Where(&Member{Age: 0, Email: "[email protected]"}) matched 1 row (struct condition ignores Age 0: 1), because the zero Age was dropped from the condition. Where(map[string]any{"age": 0}) kept the zero and matched 0 rows (map condition keeps age 0: 0). Use a map or a string condition when zero is a real value.
Update
| Need | Call |
|---|---|
| One column | db.Model(&m).Update("age", 42) |
| Several, skipping zero values | db.Model(&m).Updates(Member{Email: e}) |
| Several, including zero | db.Model(&m).Updates(map[string]any{"age": 0}) |
| Expression | db.Model(&Member{}).Where(...).Update("age", gorm.Expr("age + ?", 1)) |
Without hooks and updated_at | UpdateColumn, UpdateColumns |
| Every field | db.Save(&m) (careful) |
The output update: struct skipped Age 0, map wrote it: [email protected] 0 shows the difference: the struct form changed the email and ignored the zero age, and the map form wrote the 0. The expression update changed 2 rows (expr update rows: 2). Update and Updates on a missing id return no error and 0 rows affected, so check RowsAffected.
Delete
| Need | Call |
|---|---|
| By key | db.Delete(&Member{}, id) |
| Condition | db.Where("age < ?", 18).Delete(&Member{}) |
| Real delete (with soft delete) | db.Unscoped().Delete(&m) |
| Include soft deleted in reads | db.Unscoped().Find(&ms) |
| Everything | db.Where("1 = 1").Delete(&Member{}), since a delete with no condition is refused |
With soft delete the program counted soft delete: 2 3 (2 visible, 3 including the deleted one). The hard delete then failed because the member still had rows in member_skills: hard delete with join rows: true. After Association("Skills").Clear() it worked, leaving 2 rows.
Associations
| Need | Call |
|---|---|
| Load a has many or many to many | db.Preload("Members").Find(&teams) |
| Nested | db.Preload("Members.Skills") |
| Filter the preload | db.Preload("Members", "age > ?", 30) |
| Load a belongs to | db.Joins("Team").Find(&ms) |
| Add a link | db.Model(&m).Association("Skills").Append(&s) |
| Replace links | ...Association("Skills").Replace(&a, &b) |
| Remove links | ...Association("Skills").Delete(&s) or .Clear() |
| Count links | ...Association("Skills").Count() |
| Do not save children | db.Omit(clause.Associations) |
The program printed preload nested: [email protected] 2 skills and joins: 2 core. Association mode edits links and never deletes the other side's rows.
Transactions and errors
| Need | Call |
|---|---|
| Closure | db.Transaction(func(tx *gorm.DB) error { ... }) |
| Manual | tx := db.Begin(), then Commit or Rollback |
| Savepoint | tx.SavePoint("a"), tx.RollbackTo("a") |
| Not found | errors.Is(err, gorm.ErrRecordNotFound) |
| Duplicate | errors.Is(err, gorm.ErrDuplicatedKey) (needs TranslateError) |
| Missing where | gorm.ErrMissingWhereClause |
transaction: abort skills: 2 shows the rollback: the skill created inside the closure was undone, leaving the two created earlier. not found: true and duplicate: true confirm the sentinel errors. Only First, Take and Last return ErrRecordNotFound; Find returns an empty slice.
Raw SQL
| Need | Call |
|---|---|
| Read into a struct or slice | db.Raw("select ... where id = ?", id).Scan(&dest) |
| Write | db.Exec("update ... where id = ?", id) |
| Named values | sql.Named("n", v) with @n in the SQL |
| Fragment inside the builder | db.Where("lower(name) like ?", p) |
The aggregate query printed raw: [{core 2}]: the team name and its two live members, after the soft deleted one was left out by the explicit deleted_at is null in the SQL. Raw SQL does not add the soft delete condition.
Five rules that prevent most bugs
- Pass a context on every call.
- Pointer in, pointer out for writes and finders.
- Use maps for updates and conditions when zero is a legitimate value.
- Check
.Error, andRowsAffectedfor writes. - Never concatenate values into SQL, including inside
Where.
The reference for every method is the GORM documentation, and the source of the error values is in pkg.go.dev for gorm.


