If you're working with Go services that talk to a relational database, chances are you've bumped into GORM. It's the most widely used ORM in the Go ecosystem, and for good reason. It wraps a lot of the tedium of database/sql ,manual scanning, hand-written migrations, string-built queries in a much friendlier API.
This article walks through GORM from setup to the patterns you'll actually use day to day: models, migrations, CRUD, associations, transactions, and a few gotchas that trip people up.
Why reach for an ORM in Go ?
Go's standard database/sql package is deliberately low-level. You write SQL strings, manually scan rows into structs, and manage connections yourself. That's fine for small projects, but it gets repetitive fast once you have a dozen tables and endpoints that all need similar create/read/update/delete logic.
GORM sits on top of database/sql and gives you:
- Struct-based models mapped to tables
- Auto migrations
- A chainable query builder
- Associations (has-one, has-many, many-to-many, belongs-to)
- Hooks (before/after create, update, delete)
- Built-in support for transactions, connection pooling, and prepared statements
It supports PostgreSQL, MySQL, SQLite, SQL Server, and more, through swappable drivers.
Installation
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres
Swap postgres for mysql, sqlite, or sqlserver depending on your database.
Connecting to a database
package main
import (
"log"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
dsn := "host=localhost user=postgres password=secret dbname=myapp port=5432 sslmode=disable"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("failed to get generic db object: %v", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
}
That db.DB() call gives you the underlying *sql.DB, which is where connection pool settings live. It's easy to forget this step and end up with an ORM that opens far more connections than your database can handle.
Defining models
GORM models are plain structs with tags:
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:100;not null"`
Email string `gorm:"uniqueIndex;not null"`
Posts []Post
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Post struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:255;not null"`
Body string
UserID uint
}
-
CreatedAt/UpdatedAtare populated automatically by GORM , no extra code needed. -
DeletedAt gorm.DeletedAtenables soft deletes. Callingdb.Delete(&user)won't actually remove the row; it setsdeleted_atand every future query filters those rows out automatically. - Field-level tags (
size,not null,uniqueIndex) get translated into actual SQL constraints during migration.
Auto migrations
db.AutoMigrate(&User{}, &Post{})
This creates tables if they don't exist and adds missing columns/indexes. It won't drop columns or change types that could cause data loss , which is a deliberate safety choice, but it also means AutoMigrate isn't a full substitute for a proper migration tool once you're in production. Many teams use it for local dev and rely on something like golang-migrate or atlas for production schema changes.
Basic CRUD
Create:
user := User{Name: "Amina Otieno", Email: "amina@example.com"}
result := db.Create(&user)
if result.Error != nil {
log.Println(result.Error)
}
log.Println("New user ID:", user.ID)
Read:
var user User
db.First(&user, 1)
db.First(&user, "email = ?", "amina@example.com")
var users []User
db.Where("name LIKE ?", "%Otieno%").Find(&users)
Update:
db.Model(&user).Update("name", "Amina O.")
// Update multiple fields
db.Model(&user).Updates(User{Name: "Amina O.", Email: "new@example.com"})
Note: Updates with a struct only updates non-zero fields. If you need to set a field to its zero value (empty string, 0, false), use a map[string]interface{} instead.
Delete:
db.Delete(&user)
Associations and preloading
Given the User/Post relationship above, GORM can eager-load associations to avoid N+1 query problems:
var users []User
db.Preload("Posts").Find(&users)
This runs two queries total (one for users, one for all related posts), rather than one query per user. If you only need a subset of associated records, Preload accepts conditions too:
db.Preload("Posts", "created_at > ?", someDate).Find(&users)
For many-to-many relationships, GORM manages the join table for you:
type Tag struct {
ID uint
Name string
Posts []Post `gorm:"many2many:post_tags;"`
}
Transactions
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err // rolls back
}
if err := tx.Create(&Post{Title: "First post", UserID: user.ID}).Error; err != nil {
return err
}
return nil
})
if err != nil {
log.Println("transaction failed:", err)
}
This is the pattern you want anywhere multiple writes need to succeed or fail together — say, deducting a balance and recording a ledger entry.
Hooks
GORM calls certain methods automatically if your model defines them:
func (u *User) BeforeCreate(tx *gorm.DB) error {
u.Email = strings.ToLower(u.Email)
return nil
}
Available hooks include BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate, BeforeDelete, AfterDelete, and their *Save equivalents. Useful for normalization, validation, or audit logging without cluttering your handler code.
Things worth knowing
-
Zero values in updates. As mentioned above,
Updates()with a struct silently skips zero-valued fields. This bites people when they try to clear a field to""or0. -
Soft delete surprises. If
DeletedAtis present on a model, everyFind/First/Wherecall filters out soft-deleted rows by default. To include them, use.Unscoped(). -
N+1 queries. Forgetting
Preloadon associations is the most common performance issue in GORM codebases. Turn onlogger.Infomode in development so you can actually see the queries GORM is generating. -
Context propagation. Use
db.WithContext(ctx)in request-scoped code so query cancellation and timeouts actually work with your HTTP handler's context. -
Connection pool tuning. Don't skip
SetMaxOpenConns/SetMaxIdleConns, the defaults aren't tuned for production load.
GORM won't eliminate the need to understand SQL , and you shouldn't want it to , but it removes a lot of boilerplate around scanning rows, building migrations, and wiring up associations. For most Go backend services, especially ones backed by PostgreSQL, it hits a solid balance between productivity and control.