Right, so you’ve heard of GORM. Of course you have. It’s the Go ORM that’s so popular it’s practically the default choice for many, and for good reason. It feels like it does the heavy lifting for you. But let’s be clear: an ORM is a set of training wheels, not a self-driving car. GORM’s magic is powerful, but magic you don’t understand will eventually bite you. My job is to show you where the teeth are.

At its core, GORM is a full-featured, developer-friendly ORM that leans heavily on Go’s struct tags and reflection to map your Go structs to database tables. It promises a world where you can think in objects and let it handle the messy SQL. And for a lot of tasks, it delivers. But that convenience comes with a tax, payable in performance overhead and, sometimes, bewildering behavior when it generates SQL you didn’t quite expect.

Your First Model and the Magic of Conventions

GORM runs on conventions. It’s opinionated, and if you follow its rules, a lot of things just work. The most important convention is the gorm.Model embedded struct. Let’s break it down.

package main

import (
    "time"
    "gorm.io/gorm"
)

// This is what's actually inside the gorm.Model shorthandyou'll use.
type BaseModel struct {
    ID        uint           `gorm:"primaryKey"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

// This is how you'd typically use it.
type User struct {
    gorm.Model // Embeds the fields from BaseModel above
    Name         string
    Email        string `gorm:"uniqueIndex"` // Useful tag for uniqueness
    Age          uint8
    Birthday     *time.Time // Use a pointer for optional fields
}

func main() {
    // ... connection logic (db, err := gorm.Open(...))
    db.AutoMigrate(&User{}) // This will create the "users" table
}

Why is this slick? By embedding gorm.Model, you get ID, CreatedAt, UpdatedAt, and DeletedAt for free. The AutoMigrate method is GORM’s party trick: it reads your struct, understands the tags, and creates the corresponding table. The CreatedAt and UpdatedAt fields are automatically populated by GORM on creates and updates. It’s genuinely helpful.

But here’s the first rough edge: DeletedAt enables GORM’s soft delete by default. When you call db.Delete(&user), it doesn’t issue a DELETE statement; it updates the deleted_at column to the current time. Every subsequent query will silently include AND deleted_at IS NULL to filter out “deleted” records. This is a fantastic feature for some applications and a horrifying, data-destroying surprise for others. You can override it with db.Unscoped().Delete(&user) for a hard delete, but you must know it exists.

CRUD Operations: The Simple Stuff

Creating and reading records is where GORM feels like a superpower.

// Create
birthday := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)
user := User{Name: "Jin", Email: "jin@example.com", Age: 33, Birthday: &birthday}
result := db.Create(&user) // Pass a pointer so GORM can write back the generated ID
if result.Error != nil {
    panic(result.Error)
}
fmt.Println(user.ID) // The ID is now populated after Create.

// Read
var retrievedUser User
db.First(&retrievedUser, "email = ?", "jin@example.com") // SELECT * FROM users WHERE email = 'jin@example.com' LIMIT 1;
// WARNING: First returns an error if no record is found. Use `Error` checks.

var users []User
db.Where("age > ?", 30).Find(&users) // SELECT * FROM users WHERE age > 30;

The First, Last, Take, and Find methods are your workhorses. It’s intuitive. The problem isn’t the happy path; it’s the unhappy one. db.First(&user, 999) will return a ErrRecordNotFound error if no user with ID 999 exists. You must check for this. A common pitfall is assuming an empty struct means “not found”—it doesn’t. It means your struct’s fields are at their zero values. Always, always check result.Error.

Associations and the N+1 Problem Trap

This is where ORMs get complex and GORM is no exception. Let’s say a User has many Pets.

type Pet struct {
    gorm.Model
    Name   string
    UserID uint // This foreign key is the crucial convention
}

type User struct {
    gorm.Model
    Name  string
    Pets  []Pet // This is the "has many" relationship
}

// To create a user with pets, you do it in a transaction.
err := db.Transaction(func(tx *gorm.DB) error {
    user := User{Name: "Alice", Pets: []Pet{
        {Name: "Zoro"},
        {Name: "Cheshire"},
    }}
    return tx.Create(&user).Error // This creates the user AND their pets in one go.
})

This is elegant. But the real danger comes when reading data. How do you get a user and all their pets?

var user User
db.First(&user, 1)          // Query 1: Fetches the user.
db.Model(&user).Association("Pets").Find(&user.Pets) // Query 2: Fetches the pets.

Congratulations, you’ve just made 2 queries for what should be a single JOIN. This is the infamous N+1 problem. For a list of 100 users, this would make 101 queries. It will grind your application to a halt.

The solution is eager loading with Preload:

var smartUser User
db.Preload("Pets").First(&smartUser, 1) // Makes one query with a JOIN or two sequential SELECTs

You must remember to use Preload for any associated data you know you’ll need. GORM won’t do it for you, because it can’t read your mind. This is the single biggest performance gotcha for newcomers.

The Raw SQL Escape Hatch

When GORM’s abstraction inevitably cracks—maybe you need a complex window function or a CTE—its best feature is its easy escape hatch: raw SQL.

type AnnualUserCount struct {
    Year  int `gorm:"column:year"`
    Count int `gorm:"column:user_count"`
}

var reports []AnnualUserCount
db.Raw(`
    SELECT
        EXTRACT(YEAR FROM created_at) AS year,
        COUNT(*) as user_count
    FROM users
    WHERE deleted_at IS NULL
    GROUP BY year
    ORDER BY year;
`).Scan(&reports) // Use .Scan to dump results into a struct or map

This is non-negotiable. Never twist yourself into a knot trying to force a complex query into GORM’s fluent chain. Use db.Raw(...).Scan(...) and be done with it. It’s the perfect blend of GORM’s connection handling and your SQL expertise.

So, should you use GORM? Absolutely, for rapid prototyping, for applications where developer velocity is paramount, and for teams less comfortable with raw SQL. But you must use it with respect. Understand the SQL it writes under the hood (enable logger to see it), be militant about using Preload, and never be afraid to drop down to raw SQL. It’s a brilliant tool, but it’s not a substitute for knowing what’s happening in your database.