GORM is Go’s most widely used ORM. It maps database tables to Go structs, provides a chainable query API, handles migrations, and supports associations. The tradeoff compared to raw database/sql is abstraction — GORM generates SQL for you, which is convenient but means you must understand the queries it generates to avoid performance problems.
The most important GORM concept for production is Preloading associations — failing to do so correctly produces the N+1 query problem that destroys performance. This guide covers that explicitly alongside the core API.
For raw SQL access see Go database fundamentals. For query building see Go SQL query building.
Setup and Connection
go get gorm.io/gorm
go get gorm.io/driver/postgres # or mysql, sqlite, sqlserver
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func openDB(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
// Log slow queries in production — invaluable for catching N+1 problems
Logger: logger.Default.LogMode(logger.Warn),
})
if err != nil {
return nil, fmt.Errorf("gorm.Open: %w", err)
}
// Configure the underlying connection pool
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
return db, nil
}
logger.Warn logs queries that take longer than 200ms (the default slow query threshold). Set logger.Info in development to see all generated SQL — this is how you verify GORM is generating what you expect.
Defining Models
GORM uses struct tags to map fields to columns. Embedding gorm.Model adds ID, CreatedAt, UpdatedAt, and DeletedAt (soft delete):
type User struct {
gorm.Model // adds ID, CreatedAt, UpdatedAt, DeletedAt
Name string `gorm:"not null;index"`
Email string `gorm:"uniqueIndex;not null"`
Age int
Role string `gorm:"default:user"`
Posts []Post `gorm:"foreignKey:UserID"` // has-many
}
type Post struct {
gorm.Model
Title string `gorm:"not null"`
Content string `gorm:"type:text"`
UserID uint `gorm:"not null;index"` // foreign key
User User // belongs-to (no gorm tag needed)
Tags []Tag `gorm:"many2many:post_tags"` // many-to-many
}
type Tag struct {
gorm.Model
Name string `gorm:"uniqueIndex"`
Posts []Post `gorm:"many2many:post_tags"`
}
Run auto-migration to create tables from your models:
db.AutoMigrate(&User{}, &Post{}, &Tag{})
AutoMigrate adds missing columns and indexes but does not drop or modify existing ones — it’s safe to run on startup. For production schema changes, use a migration tool like golang-migrate for explicit, versioned migrations.
CRUD Operations
Create
user := &User{Name: "Alice", Email: "[email protected]", Age: 30}
if err := db.Create(user).Error; err != nil {
return fmt.Errorf("create user: %w", err)
}
// user.ID is populated after Create
fmt.Println("created user ID:", user.ID)
For bulk inserts, CreateInBatches is significantly faster than creating one at a time:
users := []User{
{Name: "Alice", Email: "[email protected]"},
{Name: "Bob", Email: "[email protected]"},
}
// 100 rows per INSERT statement
if err := db.CreateInBatches(users, 100).Error; err != nil {
return err
}
Read
First finds the first record ordered by primary key:
var user User
if err := db.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("user %d not found", id)
}
return nil, fmt.Errorf("get user: %w", err)
}
Find returns all matching records into a slice:
var users []User
if err := db.Where("age > ? AND role = ?", 18, "user").
Order("created_at DESC").
Limit(50).
Find(&users).Error; err != nil {
return nil, err
}
Pluck extracts a single column into a slice without scanning full structs:
var emails []string
db.Model(&User{}).Pluck("email", &emails)
Update
Save does a full update (all fields):
user.Name = "Alice Smith"
db.Save(&user)
Updates does a partial update — only the fields in the map or struct are changed:
// Update specific fields
db.Model(&user).Updates(map[string]any{
"name": "Alice Smith",
"age": 31,
})
// Or with a struct (zero values are skipped — use map for fields that can be zero)
db.Model(&user).Updates(User{Name: "Alice Smith"})
Delete
GORM implements soft delete automatically when the model has DeletedAt gorm.DeletedAt. The record is marked deleted but stays in the database:
db.Delete(&user) // sets deleted_at — record stays in DB
// To permanently delete
db.Unscoped().Delete(&user)
// Soft-deleted records are excluded from queries automatically
// To include them:
db.Unscoped().Find(&users)
The N+1 Problem and Preloading
N+1 is the most common ORM performance issue. It looks like this:
// ❌ N+1: 1 query to get users, then 1 query per user to get their posts
var users []User
db.Find(&users)
for _, user := range users {
var posts []Post
db.Where("user_id = ?", user.ID).Find(&posts)
// Each iteration executes a separate SQL query
}
For 100 users, this runs 101 queries instead of 2. GORM’s Preload fixes this by loading all associations in a second batch query:
// ✅ 2 queries total regardless of how many users
var users []User
db.Preload("Posts").Find(&users)
// SELECT * FROM users;
// SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);
Nested preloading and conditional preloading:
// Preload Posts and each Post's Tags
db.Preload("Posts.Tags").Find(&users)
// Preload only recent posts
db.Preload("Posts", "created_at > ?", time.Now().AddDate(0, -1, 0)).Find(&users)
// Preload with custom ordering
db.Preload("Posts", func(db *gorm.DB) *gorm.DB {
return db.Order("posts.created_at DESC")
}).Find(&users)
Enable GORM’s SQL logging and watch for repeated similar queries — that’s N+1 in the wild.
Scopes: Reusable Query Conditions
Scopes are functions that add conditions to a query — they compose naturally and keep query logic out of handlers:
func active(db *gorm.DB) *gorm.DB {
return db.Where("deleted_at IS NULL AND active = ?", true)
}
func recentlyCreated(days int) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
return db.Where("created_at > ?", time.Now().AddDate(0, 0, -days))
}
}
func paginate(page, pageSize int) func(*gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
offset := (page - 1) * pageSize
return db.Offset(offset).Limit(pageSize)
}
}
// Combine scopes
var users []User
db.Scopes(active, recentlyCreated(30), paginate(2, 20)).Find(&users)
Hooks: Lifecycle Callbacks
GORM calls hooks before and after database operations. Use them for validation, encryption, and side effects:
func (u *User) BeforeCreate(tx *gorm.DB) error {
if u.Email == "" {
return errors.New("email is required")
}
// Hash password before storing
if u.Password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.PasswordHash = string(hash)
u.Password = "" // don't store the plaintext
}
return nil
}
func (u *User) AfterCreate(tx *gorm.DB) error {
// Send welcome email asynchronously — don't block the transaction
go sendWelcomeEmail(u.Email)
return nil
}
Available hooks: BeforeCreate, AfterCreate, BeforeSave, AfterSave, BeforeUpdate, AfterUpdate, BeforeDelete, AfterDelete, BeforeFind, AfterFind.
Transactions
GORM’s Transaction method handles begin/commit/rollback automatically:
func createPostWithTags(db *gorm.DB, post *Post, tagNames []string) error {
return db.Transaction(func(tx *gorm.DB) error {
// All operations here use the same transaction
if err := tx.Create(post).Error; err != nil {
return err // returning error triggers automatic rollback
}
for _, name := range tagNames {
tag := &Tag{Name: name}
// FirstOrCreate avoids duplicate key errors
if err := tx.FirstOrCreate(tag, Tag{Name: name}).Error; err != nil {
return err
}
if err := tx.Model(post).Association("Tags").Append(tag); err != nil {
return err
}
}
return nil // returning nil triggers automatic commit
})
}
For manual transaction control, use db.Begin():
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := tx.Create(&user).Error; err != nil {
tx.Rollback()
return err
}
tx.Commit()
Raw SQL When You Need It
GORM doesn’t cover every SQL feature. For complex queries, use Raw and Exec:
// Raw SELECT into a struct slice
var results []struct {
Department string
Count int
AvgAge float64
}
db.Raw(`
SELECT department, COUNT(*) as count, AVG(age) as avg_age
FROM users
GROUP BY department
HAVING COUNT(*) > ?
`, 5).Scan(&results)
// Raw UPDATE/DELETE
db.Exec("UPDATE users SET last_seen = ? WHERE id = ?", time.Now(), userID)
Common Mistakes
Using Save for partial updates. Save updates all fields, including zero values — if Age is 0, it sets the database column to 0. Use Updates with a map for partial updates.
Forgetting Preload for associations. GORM doesn’t load associations automatically. Accessing user.Posts without preloading gives you an empty slice silently, or triggers N+1 queries if you load inside a loop.
Ignoring .Error. GORM chains operations and accumulates errors. Always check .Error at the end of a chain, especially for creates and updates.
AutoMigrate in production. AutoMigrate is convenient for development but dangerous in production — use versioned migration files instead.
Summary
- Configure
SetMaxOpenConns,SetMaxIdleConnson the underlying*sql.DBretrieved viadb.DB() - Enable SQL logging (
logger.Info) during development to verify GORM generates the queries you expect - Use
Preloadto avoid N+1 queries — one preload per association, not per record - Use
Updateswith a map for partial updates;Saveoverwrites all fields including zero values gorm.Transactionhandles begin/commit/rollback — return an error to trigger rollback, return nil to commit- Check
errors.Is(err, gorm.ErrRecordNotFound)for missing records — it’s not an error, it’s “not found”
Comments