GORM basics¶
GORM maps structs to tables and generates SQL. It removes most of the
Scan boilerplate from
database/sql, and it
introduces behaviours you have to know about — one of which writes the
wrong value to your database without telling you.
Modules:
gorm.io/gormandgorm.io/driver/postgres.
Opening¶
db, err := gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
SkipDefaultTransaction: true,
})
*gorm.DB wraps a database/sql pool, so pool tuning still happens
there:
Two config options worth setting deliberately. SkipDefaultTransaction
turns off the implicit transaction GORM wraps around every single
write — a measurable saving when you are managing transactions
yourself. And set the Logger level explicitly, because the default
logs every slow query to stdout in a format nothing parses.
Models¶
type Base struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
}
type Book struct {
Base
Title string `gorm:"not null"`
AuthorID uuid.UUID `gorm:"type:uuid;index"`
Draft bool `gorm:"not null;default:true"`
Notes *string
Ignored string `gorm:"-"`
}
Embedding a Base gives every table its id and timestamps — struct
embedding from structs, with the
tags promoted along with the fields.
| Tag | Effect |
|---|---|
primaryKey |
the primary key |
type:uuid |
the SQL column type |
not null, unique, index |
constraints |
default:expr |
a column default — see the trap below |
column:name |
override the derived column name |
autoCreateTime / autoUpdateTime |
maintained by GORM |
- |
not persisted |
Names are derived by convention: Book → books, AuthorID →
author_id. A typo in a column: tag compiles fine and silently maps
to the wrong column, which is why a schema-drift check is worth having.
A *string is a nullable column; a plain string is NOT NULL with
'' as its zero. The distinction from
encoding JSON applies
here too.
Postgres array columns need lib/pq¶
GORM running on the pgx driver still does not map a Postgres text[]
to a []string by itself. The type that does is pq.StringArray, from
github.com/lib/pq:
type Tag struct {
ID int64 `gorm:"primaryKey"`
Name string `gorm:"uniqueIndex"`
Langs pq.StringArray `gorm:"type:text[];not null;default:'{}'"`
}
t := Tag{Name: "go", Langs: pq.StringArray{"go", "python"}}
db.Create(&t)
var back Tag
db.First(&back, "name = ?", "go")
fmt.Println(back.Langs, len(back.Langs)) // output: [go python] 2
This surprises people, because
pgx maps arrays to []string natively — but
that is pgx's own API. Go through GORM and you are back to a
driver.Valuer/sql.Scanner type, which is what pq.StringArray is.
There are pq.Int64Array and friends for the other element types, and
pq.Array(&v) wraps a slice for a one-off query.
So a codebase can depend on lib/pq purely for these types while using
pgx as the actual driver. That is not a mistake; it is the normal
arrangement.
The default: zero-value trap¶
This is the one to internalise. GORM omits zero-valued fields from the
generated INSERT, so the database default applies instead of the
value you set:
b := Book{Title: "Explicitly not a draft", Draft: false}
db.Create(&b)
var back Book
db.First(&back, "id = ?", b.ID)
fmt.Println(back.Draft) // output: true
You wrote false. The database holds true. No error anywhere.
The commonly-repeated fix is Select. It does not work — all three
forms still produce true:
Two things actually fix it. A pointer field, where nil and
&false are distinguishable:
type Book struct {
Draft *bool `gorm:"not null;default:true"`
}
f := false
db.Create(&Book{Title: "x", Draft: &f}) // stored: false
Or create from a map, which has no zero values to skip:
The same trap applies to Updates with a struct:
Updates with a map updates exactly what you list:
The simplest defence is to avoid default: on booleans and numbers
entirely, and set the value in Go. If you need the database default,
make the field a pointer.
Reading¶
Pass a context with WithContext on every call. Without it the query
has no cancellation, exactly as with the plain driver.
First returns a sentinel when nothing matches:
err := db.First(&book, "title = ?", "nope").Error
fmt.Println(errors.Is(err, gorm.ErrRecordNotFound)) // output: true
Find does not:
var books []Book
r := db.Where("title = ?", "nope").Find(&books)
fmt.Println(r.Error, r.RowsAffected, len(books))
// output: <nil> 0 0
An empty result is not an error for a list query. Check
RowsAffected or len, not Error.
Every call returns a *gorm.DB carrying Error and RowsAffected.
Checking .Error is the equivalent of if err != nil, and forgetting
it is the easiest mistake to make here — nothing in the type system
requires it.
Writing¶
Create fills the primary key and timestamps back into your struct.
Constraint violations surface as the driver's error, so
errors.As on *pgconn.PgError still works:
Hooks¶
Methods with reserved names run around operations:
func (b *Book) BeforeCreate(tx *gorm.DB) error {
if b.Title == "" {
return errors.New("title is required")
}
return nil
}
Returning an error aborts the write. Also available: AfterCreate,
BeforeUpdate, BeforeDelete and others.
Use them sparingly. A hook is behaviour that fires invisibly from the call site, which makes it hard to trace — validation is usually clearer in the service layer.
AutoMigrate is for development¶
It creates tables and adds missing columns. It will not drop columns, change types safely, or produce a reviewable diff, and it has no notion of running once. Use it in tests and local development; use goose for anything you deploy.
Associations¶
type Author struct {
Base
Name string `gorm:"not null;unique"`
Books []Book `gorm:"foreignKey:AuthorID"`
}
Without Preload, a.Books is empty — GORM does not lazy-load. Be
deliberate: preloading a list endpoint's associations is how you get
an N+1 query pattern, and Joins is often the better answer.
From Python: GORM is roughly SQLAlchemy's ORM with Django-style tags instead of a declarative schema. The zero-value trap has no Python equivalent, because
NoneandFalseare distinct there — in Go they are the samefalseunless you use a pointer.
Quick reference¶
| Task | Form |
|---|---|
| open | gorm.Open(postgres.New(...), &gorm.Config{}) |
| pool tuning | db.DB() then the database/sql setters |
| context | db.WithContext(ctx) on every call |
| check for failure | .Error on the returned *gorm.DB |
| not found | errors.Is(err, gorm.ErrRecordNotFound) — First only |
| empty list | Find returns no error; check RowsAffected |
zero value + default: |
use a *bool, or Create/Updates with a map |
| associations | Preload("Books") — never lazy |
| schema | AutoMigrate in dev, real migrations in production |