Skip to content
gohacks.dev
Databases

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

8 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

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

main.goGo
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)
}
Text
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 $2

Connect and configure

NeedCall
Opengorm.Open(postgres.Open(dsn), &gorm.Config{...})
Map driver errorsTranslateError: true
Silence logsLogger: logger.Discard
Skip the implicit transactionSkipDefaultTransaction: true
PoolsqlDB, _ := db.DB() then SetMaxOpenConns, SetMaxIdleConns
Always pass a contextdb.WithContext(ctx)
Preview SQLdb.Session(&gorm.Session{DryRun: true})...Statement.SQL.String()
Print one statementdb.Debug()

Create

NeedCall
One rowdb.Create(&row) and read row.ID
Many rowsdb.Create(&slice) (mind 65535 parameters)
Large setsdb.CreateInBatches(&slice, 1000)
Skip a fielddb.Omit("Author").Create(&row)
Only some fieldsdb.Select("Name", "Age").Create(&row)
Find or createdb.Where(Tag{Name: n}).FirstOrCreate(&tag)
Insert if absentdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
UpsertOnConflict{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

NeedCall
By primary keydb.First(&m, 42)
One row, no orderingdb.Take(&m)
Newestdb.Last(&m)
With a conditiondb.First(&m, "email = ?", e)
Manydb.Where("age >= ?", 30).Order("age desc").Limit(5).Find(&ms)
Countdb.Model(&Member{}).Where(...).Count(&n)
One columndb.Model(&Member{}).Pluck("email", &emails)
Selected columnsdb.Select("id", "email").Find(&ms)
INdb.Where("id IN ?", ids).Find(&ms)
Pagingdb.Order("id").Offset(40).Limit(20) or keyset with id > ?
Lockingdb.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

NeedCall
One columndb.Model(&m).Update("age", 42)
Several, skipping zero valuesdb.Model(&m).Updates(Member{Email: e})
Several, including zerodb.Model(&m).Updates(map[string]any{"age": 0})
Expressiondb.Model(&Member{}).Where(...).Update("age", gorm.Expr("age + ?", 1))
Without hooks and updated_atUpdateColumn, UpdateColumns
Every fielddb.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

NeedCall
By keydb.Delete(&Member{}, id)
Conditiondb.Where("age < ?", 18).Delete(&Member{})
Real delete (with soft delete)db.Unscoped().Delete(&m)
Include soft deleted in readsdb.Unscoped().Find(&ms)
Everythingdb.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

NeedCall
Load a has many or many to manydb.Preload("Members").Find(&teams)
Nesteddb.Preload("Members.Skills")
Filter the preloaddb.Preload("Members", "age > ?", 30)
Load a belongs todb.Joins("Team").Find(&ms)
Add a linkdb.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 childrendb.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

NeedCall
Closuredb.Transaction(func(tx *gorm.DB) error { ... })
Manualtx := db.Begin(), then Commit or Rollback
Savepointtx.SavePoint("a"), tx.RollbackTo("a")
Not founderrors.Is(err, gorm.ErrRecordNotFound)
Duplicateerrors.Is(err, gorm.ErrDuplicatedKey) (needs TranslateError)
Missing wheregorm.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

NeedCall
Read into a struct or slicedb.Raw("select ... where id = ?", id).Scan(&dest)
Writedb.Exec("update ... where id = ?", id)
Named valuessql.Named("n", v) with @n in the SQL
Fragment inside the builderdb.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

  1. Pass a context on every call.
  2. Pointer in, pointer out for writes and finders.
  3. Use maps for updates and conditions when zero is a legitimate value.
  4. Check .Error, and RowsAffected for writes.
  5. 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.