Skip to main content

GORM: ORM in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 7 min read

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, SetMaxIdleConns on the underlying *sql.DB retrieved via db.DB()
  • Enable SQL logging (logger.Info) during development to verify GORM generates the queries you expect
  • Use Preload to avoid N+1 queries — one preload per association, not per record
  • Use Updates with a map for partial updates; Save overwrites all fields including zero values
  • gorm.Transaction handles 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”

Resources

Comments

👍 Was this article helpful?