Skip to content
gohacks.dev
Databases

GORM in Go From Models to Migrations and Queries Explained

A GORM tutorial for Go and PostgreSQL covering models, AutoMigrate, create, read, update and delete, with a run that shows what First and Find do on no rows

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

GORM is the object relational mapper most Go developers meet first. You describe a table as a struct, and GORM turns method calls into SQL, scans the rows back into structs and keeps a few conventions so that a small program needs very little code. This tutorial builds one such program against PostgreSQL 18.6, runs it, and reads the output line by line, because the behaviour GORM chooses on a missing row or a partial update is what decides whether your handlers are correct.

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)

It is the entry point to the GORM cluster of the database cornerstone. The later articles go deeper on models and tags, associations, transactions and performance. If you prefer to write SQL and have types generated, the sqlc tutorial is the other end of the spectrum, and database/sql explained covers the layer GORM sits on.

What GORM is, and what it sits on

GORM does not talk to the database itself. Underneath it holds a database/sql connection pool, and a driver package decides the SQL dialect. For PostgreSQL that is gorm.io/driver/postgres, which uses the pgx driver. Everything you know about pool size, timeouts and connection limits therefore still applies, and GORM exposes the pool when you need it with db.DB().

What GORM adds is four things:

  • A schema parser. It reads your struct, its tags and a set of naming rules, and builds a description of a table.
  • A statement builder. Chain methods such as Where, Order and Limit build a statement, and a finisher such as Find, First, Create or Delete runs it.
  • Callbacks. Each operation runs through a list of steps: begin a transaction, run hooks, build and execute SQL, set timestamps, commit. That list is why a single Create is wrapped in a transaction by default.
  • A migrator. AutoMigrate compares your structs with the live schema and issues CREATE TABLE and ALTER TABLE ... ADD COLUMN statements.

Each is a convenience with a cost, and the cost is the reason people argue about ORMs. The honest way to evaluate GORM is to know what each of the four does and decide which ones you want. The rest of this article shows them working.

Install and connect

Add the module and a driver:

Shell
go get gorm.io/[email protected]
go get gorm.io/driver/[email protected]

gorm.Open takes a dialector and a *gorm.Config. The dialector holds the connection string, and the config holds behaviour such as the logger and whether to skip the default transaction. The lab below opens two connections: one to create a clean schema for the run, and one scoped to that schema through search_path, so the lab can be run repeatedly.

A connection string for a real service comes from configuration and not from source. The one in the lab is the throwaway Docker database used for every article in this cluster:

Shell
docker run -d --name gh-lab-pg -p 55432:5432 -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=lab postgres:18-alpine

The model

A model is a struct. GORM's conventions do most of the work: a field named ID is the primary key, the table is the plural snake case of the struct name (books), and column names are snake case versions of the field names. The lab's model uses two tags:

  • gorm:"not null" on Title becomes a NOT NULL constraint.
  • gorm:"index" on Author builds an index named idx_books_author.

The full set of tags, and the naming rules in detail, are in the models article. For a first project the defaults are enough, and the main advice is to keep the struct a plain data holder.

The program

The lab creates the schema, migrates, then walks through create, read, update and delete:

main.goGo
package main

import (
	"errors"
	"fmt"
	"log"

	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

type Book struct {
	ID     uint
	Title  string `gorm:"not null"`
	Author string `gorm:"index"`
	Pages  int
}

func must(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

func main() {
	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 l0706 cascade").Error)
	must(admin.Exec("create schema l0706").Error)

	db, err := gorm.Open(postgres.Open(base+" search_path=l0706"), &gorm.Config{Logger: logger.Discard})
	must(err)
	must(db.AutoMigrate(&Book{}))

	// Create
	b := Book{Title: "The Go Programming Language", Author: "Donovan", Pages: 380}
	res := db.Create(&b)
	must(res.Error)
	fmt.Println("created id:", b.ID, "rows:", res.RowsAffected)

	db.Create(&[]Book{{Title: "Concurrency in Go", Author: "Cox-Buday", Pages: 238}, {Title: "Learning Go", Author: "Bodner", Pages: 375}})

	// Read
	var first Book
	must(db.First(&first, 1).Error)
	fmt.Println("first:", first.Title)

	var long []Book
	db.Where("pages > ?", 300).Order("pages desc").Find(&long)
	for _, l := range long {
		fmt.Println("long:", l.Title, l.Pages)
	}

	// Update: Save writes every column, Update writes one
	db.Model(&first).Update("pages", 400)
	var after Book
	db.First(&after, 1)
	fmt.Println("pages after update:", after.Pages)

	// Missing rows are an error from First, not from Find
	var none Book
	err = db.First(&none, 999).Error
	fmt.Println("First missing:", errors.Is(err, gorm.ErrRecordNotFound))
	var many []Book
	err = db.Where("id = ?", 999).Find(&many).Error
	fmt.Println("Find missing:", err, len(many))

	// Delete
	res = db.Delete(&Book{}, 2)
	fmt.Println("deleted rows:", res.RowsAffected)
	var count int64
	db.Model(&Book{}).Count(&count)
	fmt.Println("books left:", count)
}
Text
created id: 1 rows: 1
first: The Go Programming Language
long: The Go Programming Language 380
long: Learning Go 375
pages after update: 400
First missing: true
Find missing: <nil> 0
deleted rows: 1
books left: 2

Reading the output

Create. db.Create(&b) inserted one row and wrote the generated key back into b.ID, so the first line shows created id: 1 rows: 1. Pass a pointer, because GORM needs to fill in the struct. The rows value is res.RowsAffected, which for a single insert is 1. A slice works too, and the lab uses it to insert two more books in one statement.

GORM wrapped that insert in a transaction without being asked. Every write goes through the default transaction so that hooks and associations are atomic. If you do not use hooks and want one fewer round trip per write, set SkipDefaultTransaction: true in the config. The performance mistakes article measures what that is worth.

Read. First(&first, 1) loaded the book with primary key 1, and the program printed its title. First orders by primary key and adds LIMIT 1. Where("pages > ?", 300).Order("pages desc").Find(&long) returned the two books longer than 300 pages in descending order, The Go Programming Language (380) and then Learning Go (375). The placeholder ? is how you pass values safely: GORM sends them as bind parameters, so they cannot change the shape of the statement.

Update. db.Model(&first).Update("pages", 400) issued UPDATE books SET pages=400, ... WHERE id = 1, and the reload printed pages after update: 400. Update writes one column. Updates takes a struct or a map and writes several, and the difference between the two forms matters: with a struct, GORM skips zero values, so Updates(Book{Pages: 0}) writes nothing. Use a map when you mean to set a field to its zero value. Save, by contrast, writes every column, including zero ones, and creates the row if the primary key is zero. It is the right call when you hold a complete, current struct, and the wrong one when you loaded a partial row, because it will overwrite columns you never meant to touch.

A missing row. This is the first behaviour to learn. First(&none, 999) returned an error, and errors.Is(err, gorm.ErrRecordNotFound) was true. Find with a Where that matched nothing returned a nil error and a slice of length 0, as the lab printed: Find missing: <nil> 0. The rule is that the finishers that return a single record (First, Take, Last) treat no row as an error, and Find into a slice does not. A handler that calls Find into a struct, not a slice, gets no error and a zero struct, and the bug shows up three layers later as a book with an empty title. When you want one row, use First or Take and check the error.

Delete. db.Delete(&Book{}, 2) removed the row with key 2 and reported RowsAffected of 1. A count afterwards returned 2 books left. The count of rows affected is the way to distinguish a delete that removed something from a delete of an id that was never there, and an HTTP handler uses it to choose between 204 and 404.

GORM will refuse a Delete with no conditions. Calling db.Delete(&Book{}) returns gorm.ErrMissingWhereClause, a guard added so that a forgotten condition cannot empty a table. If you really want that, db.Where("1 = 1").Delete(&Book{}) or AllowGlobalUpdate: true states the intent. The same protection applies to Update.

Migrations: AutoMigrate and its limits

AutoMigrate(&Book{}) created the books table and its index on the first run, and it would add a missing column on a later run. It does not drop columns, does not rename them, and will not change a column's type in most cases. That makes it excellent for a prototype and test databases, and a trap for production, where a schema change deserves a reviewed, versioned migration. The AutoMigrate article lists exactly what it does and does not do, and versioned migrations with goose or Atlas shows the replacement.

Logging what GORM sends

The lab disables logging to keep the output readable. While learning, turn it on: db.Debug().First(&b, 1) prints the statement and its timing for one call, and Logger: logger.Default.LogMode(logger.Info) does it for the whole connection. Reading the SQL that your chain produced is the fastest way to learn the library, and the single most useful habit for spotting a query that runs more often than you expected. The logging article covers slow query thresholds and production settings.

Context and timeouts

Every finisher has a WithContext form: db.WithContext(ctx).First(&b, id). Pass the request's context in every handler. Without it, a client that disconnects leaves its query running, and a slow query cannot be cancelled. The context article shows the cancellation measured against a query that sleeps.

Structure a first project

A layout that works for a small service keeps GORM out of the HTTP layer:

  1. A models or store package holds structs and a Store type with methods like CreateBook and BookByID.
  2. Methods take a context.Context and return domain errors. ErrRecordNotFound is converted to your own ErrNotFound at the boundary, so handlers never import GORM.
  3. One *gorm.DB is created at start-up and shared. It is safe for concurrent use, and each chain you build from it is a new statement. Reusing a half built chain across goroutines is the one way to break that, so store the root db and chain from it on every call.

That last point catches people. After q := db.Where("a = ?", 1), the variable q is a value you can finish once, and chaining more onto q in two goroutines shares state in ways that are hard to see. Use db.Session(&gorm.Session{}) to get a clean reusable base, or build the whole chain in one expression.

When GORM fits and when it does not

GORM earns its place on CRUD heavy services with many small tables, where the speed of writing the first version matters and queries are simple. The conventions are consistent, the association handling saves a lot of boilerplate, and hooks and scopes give shared behaviour a home.

It fits less well when the work is mostly reporting queries with joins and aggregates, when you need exact control over the SQL, or when a team wants every query reviewed as SQL. In those cases the options are the raw SQL escape hatch (GORM raw SQL) or a different tool: sqlc for generated code from SQL, or hand written database/sql for full control. The comparison articles in this cluster measure those choices on the same schema instead of arguing from taste.

Configuration worth knowing on day one

gorm.Config has a long list of fields, and five of them change behaviour you will notice:

  • Logger sets the log level and the slow query threshold. The default prints only errors and slow statements.
  • SkipDefaultTransaction removes the implicit transaction around single writes. Set it only if no hook or association depends on that atomicity.
  • PrepareStmt caches prepared statements per connection. It can help a hot path and is a source of trouble behind a transaction mode connection pooler, so test it before enabling it.
  • NamingStrategy changes table and column naming. A table prefix or singular table names are one setting, set once at start-up and never changed afterwards, because changing it later renames every table GORM expects.
  • DisableForeignKeyConstraintWhenMigrating stops AutoMigrate from creating foreign keys, which some teams want when the schema is owned elsewhere.

None of these is required. The defaults work, and the advice is to change one only when you can name the problem it solves.

Mistakes that show up in the first week

Forgetting the pointer. db.Find(users) with a slice value instead of &users returns an error about an unaddressable destination. The message is clear, but it surprises people who are used to passing slices to append.

Reusing a chain. q := db.Where("active = ?", true) followed by q.Find(&a) and q.Where("x").Find(&b) can leak conditions from the second call into later ones. Treat a chain as single use, or start from db.Session(&gorm.Session{}).

Ignoring Error. Every finisher returns a *gorm.DB, and the error is the field on it. A call such as db.Create(&b) without .Error compiles and discards a constraint failure. The lab uses a small must helper for brevity, and a service should return the error to the caller.

Using Save for partial updates. Load a row, change one field on the struct, Save it, and any column another request changed in between is overwritten with the old value. Prefer Update with the single column, or add a version column and use optimistic locking.

Trusting AutoMigrate as a deploy step. It is easy to wire it into main so that every instance migrates on start. Two instances starting together race, and a column it cannot alter is skipped silently. A migration should be a separate, reviewed step.

Counting with Find. len(rows) after loading every row is a full table read. Use Model(&Book{}).Count(&n), which issues SELECT count(*).

Read models and tags for the conventions this lab relied on, then associations for has many, belongs to and many to many, and Preload and Joins before writing any list endpoint that returns related data. The N plus one trap there is the most common production problem with GORM.

Checklist

  • Pass pointers to Create, First, Find, Update and Delete, and read the generated key from the struct.
  • Use First or Take for one row and check errors.Is(err, gorm.ErrRecordNotFound). Find into a slice never returns that error.
  • Prefer Update and Updates with a map over Save when changing part of a row.
  • Check RowsAffected to tell a delete that worked from one that matched nothing.
  • Treat AutoMigrate as a development tool until you have a reviewed migration process.
  • Pass ctx with WithContext on every call, and share one *gorm.DB.

The reference for everything above is the GORM documentation, with the statement and callback details in the GORM source.

Also inTooling
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.