So, you've just shipped your first Go service. The codebase is small, the API is clean, and somewhere in the middle of it you reached for GORM because writing sql.DB ceremony for every CRUD endpoint felt like sandpaper. A week later you've got migrations, hooks, soft deletes, eager-loaded relationships, and a users.Repository that fits in fifty lines.

It feels great.

Then month six arrives.

A endpoint that should be one query is somehow five. A Find you wrote a year ago returns more rows than you expect. An "innocent" Update wipes a column nobody touched. The logs say something is being SELECTed every request, and you can't quite tell where the query is coming from because the closest thing you can find is db.Model(&User{}).Updates(payload) buried in a service method.

GORM isn't betraying you. It's doing exactly what it advertised. The trade-off you signed up for is productivity at the cost of query visibility, and that bill always comes due eventually.

This article isn't a hit piece. GORM is genuinely useful, and there are projects where reaching for it is the right call. But the convenience is not free, and the most common GORM headaches are not bugs: they're the abstraction working as designed, only nobody told you what that meant.

The honest case for GORM

Let's start with what GORM actually buys you, because if we skip this we're being unfair.

For a small-to-medium Go service backed by a relational database, this is a lot of finished work in very little code:

Go
type User struct {
    ID        uint
    Email     string `gorm:"uniqueIndex"`
    Name      string
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}

db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
db.AutoMigrate(&User{})

db.Create(&User{Email: "ada@example.com", Name: "Ada"})

var u User
db.Where("email = ?", "ada@example.com").First(&u)

db.Model(&u).Update("Name", "Ada L.")
db.Delete(&u) // soft delete because of gorm.DeletedAt

That's a schema, a migration, a unique index, an insert, a select, an update, and a soft delete. Most teams writing equivalent code with database/sql end up reinventing pieces of this themselves: a struct scanner, a placeholder generator, a half-baked migration tool, a "deleted_at IS NULL" filter helper they remember to apply most of the time.

GORM also gives you a few things that feel boring until you're missing them:

  • Hooks (BeforeCreate, AfterUpdate, BeforeDelete, etc.): for things like setting CreatedBy, emitting an event, or normalizing fields.
  • Transactions via db.Transaction(func(tx *gorm.DB) error { ... }) that automatically commit on nil and roll back on error.
  • Logger with slow-query thresholds via gorm.io/gorm/logger so the framework will yell at you when a query takes too long.
  • Scopes: reusable query fragments you can chain into any builder.

Day-one productivity is real. The first three months on a GORM codebase usually feel pretty good. The trade-offs are slower to show up, which is what makes them dangerous.

What gets hidden

Most ORMs hide the SQL. GORM hides something a little more subtle: it hides the shape of the work.

Take this:

Go
var users []User
db.Preload("Posts").Where("active = ?", true).Find(&users)

That single line of Go is not one query. It's two. GORM runs the SELECT against users, gathers the IDs, then runs a second SELECT against posts with an IN (?, ?, ?...) clause. Convenient, and for many cases, correct.

But now consider:

Go
var users []User
db.Preload("Posts").Preload("Posts.Comments").Find(&users)

Three queries. Possibly four if Comments has its own preload. None of that is wrong, but it's also not what someone reading the Go code would guess if they hadn't seen a Preload pattern before. A reader who assumes "this is one query with joins" will write production code on top of a wrong mental model.

The honest fix is db.Debug() or turning the logger up to logger.Info, which prints the generated SQL:

Go
db.Debug().Preload("Posts").Where("active = ?", true).Find(&users)

That gives you the queries, but only at the call site you bothered to instrument. There is no equivalent of staring at a SQL file and knowing what hits the database. With GORM, you have to ask the framework, every time.

This is the central trade-off: SQL is the API your database speaks, and GORM has put a polite Go-shaped translator between you and that API. Translators are great until the translation is the bug.

Side-by-side diagram: a single Go Preload call on the left, three separate SQL SELECT statements on the right, showing the hidden query cost.

The Find / First / Take confusion

GORM has roughly five methods that all look like "fetch some rows":

  • First(&out): first row by primary key ascending; errors if not found.
  • Last(&out): last row by primary key descending.
  • Take(&out): one row, no ordering; errors if not found.
  • Find(&out): multiple rows, or a single row, depending on what you pass in.
  • Scan(&out): copy results into the destination, used for arbitrary selects.

The first time you write db.Where("email = ?", e).First(&u), this is fine: a single row, ordered by ID, you get back the user. Six months in, somebody writes db.Where("tenant_id = ?", t).Find(&users) to fetch all users for a tenant, and then somebody else writes:

Go
var u User
db.Where("tenant_id = ?", t).Find(&u) // single struct, not a slice

GORM happily fills u with whatever row came back first, usually the lowest ID. No ordering, no error, no warning. The code looks correct. The bug is silent.

A small lesson: Find on a single struct is technically valid and is exactly the kind of API surface you don't want when you're tired at 11pm. Some teams ban Find(&singleStruct) in code review for this reason. There's no compile-time guard.

Struct updates skip zero values, map updates do not

This is the GORM gotcha that has bitten approximately every team I've ever talked to that uses GORM.

Go
type User struct {
    ID    uint
    Name  string
    Email string
    Plan  string
}

// payload from the API: only Name changed
payload := User{Name: "Ada L."}
db.Model(&u).Updates(payload)

That update sets name = 'Ada L.' and leaves the other columns alone. So far, so good.

Now somebody adds a "downgrade to free plan" feature:

Go
payload := User{Plan: ""}
db.Model(&u).Updates(payload)

This does nothing. Plan is the empty string, which is the zero value for a Go string, and GORM's struct-based Updates skips zero-value fields on purpose, because if it didn't, you couldn't safely use partial structs at all.

The documented fix is to use a map:

Go
db.Model(&u).Updates(map[string]any{"Plan": ""})

Now the column is set to empty string. Good. But the team is now writing two different update styles in the same codebase, the one with the foot-gun and the one without. Choose one path, document it, and be loud about it in PR review. There is no clever API design that makes this disappear: it's a deliberate trade between the two failure modes.

Method chaining mutates the db handle

This one looks fine until two requests hit the same *gorm.DB from a global variable.

Go
// db is shared
result := db.Where("active = ?", true).Find(&users)

What you might guess: Where(...) returns a new builder, leaves db alone, and you go on your way.

What actually happens: GORM's *gorm.DB is a builder and a connection handle. Some chain calls return a fresh *gorm.DB, some can carry conditions forward depending on how you got the handle. The documented safe pattern is to start every operation from a session:

Go
session := db.Session(&gorm.Session{NewDB: true})
session.Where("active = ?", true).Find(&users)

Or to never reuse the result of a chained call as your base handle. Teams that use the global db directly and chain conditions onto it eventually trip into a state where some long-lived condition leaks into a place it shouldn't. The fix is almost always "start from a Session", and the lesson is that the cute chain syntax has stateful edges most people don't notice until something goes wrong.

If you only remember one thing from this section: never store the result of db.Where(...) in a long-lived variable and reuse it for unrelated queries. Treat the builder as throwaway, every time.

Hooks are convenient and easy to overuse

GORM hooks fire automatically when records cross certain operations:

Go
func (u *User) BeforeCreate(tx *gorm.DB) error {
    u.Email = strings.ToLower(strings.TrimSpace(u.Email))
    return nil
}

Lovely. Centralized normalization. The User struct is the only place that needs to know how emails should be stored.

The problem starts when hooks grow legs.

A BeforeCreate that originally normalized email starts doing audit logging. The audit-logging hook starts calling another service. The other service occasionally times out. Now your "simple Create" can block on an HTTP call you didn't write and can't see from the call site. From the caller's perspective, it's still just db.Create(&u). The hook didn't announce itself.

A few rules that keep hooks honest:

  • Hooks should be pure to the row. Normalize fields, validate invariants. Don't make network calls.
  • If a hook needs to do work outside the row (audit log, event bus), enqueue it; don't perform it inline.
  • Resist the urge to put business logic in hooks because it "saves a few lines in the service layer." Business logic in hooks is the GORM equivalent of MySQL triggers, fine until you have to debug them.

When the abstraction stops paying

There's a point in most GORM codebases where the time you save writing CRUD is being eaten by the time you spend convincing GORM to do something specific. Some hints that you've crossed it:

  • You're using db.Raw(...) or db.Exec(...) for half of your reporting queries because they need joins, CTEs, or window functions GORM doesn't express cleanly.
  • You're spending PR review time arguing whether to use Preload or Joins for a given relationship, and the right answer keeps depending on the row count.
  • The slow-query log keeps surfacing queries you didn't realize you were running, and db.Debug() is now sprinkled across the codebase as a debugging tool that nobody removes.
  • Migrations have moved from AutoMigrate to a real migration tool (goose, migrate, atlas), because AutoMigrate is fine for greenfield and unsafe for production schema changes.

None of these mean "rip out GORM." They do mean GORM is no longer paying for the parts of the project they touch. The realistic move is to keep using GORM for the CRUD it was good at, and pull the analytical / reporting / hot-path queries down into sqlx, pgx, or hand-written database/sql for those endpoints.

Go
// Hot path: hand-written SQL with pgx
rows, err := pool.Query(ctx, `
    SELECT
        u.id,
        u.email,
        count(p.id) FILTER (WHERE p.published) AS published_posts,
        max(p.created_at)                       AS last_post_at
    FROM users u
    LEFT JOIN posts p ON p.author_id = u.id
    WHERE u.active = true
    GROUP BY u.id
    HAVING count(p.id) > 0
    ORDER BY last_post_at DESC
    LIMIT 100;
`)

That's a query you don't really want to express in GORM. You can (Select + Joins + Group + raw expressions get you most of the way), but the resulting Go reads worse than the SQL, and the SQL is the thing you'll actually look at when the query plan goes sideways.

The healthiest GORM codebases I've seen treat it as a CRUD tool, not a query language. They use it for User.Create, User.Update, Order.SoftDelete. They reach for SQL the moment the query gets interesting. And they're at peace with both.

A few habits that keep GORM honest

I'm not going to call this a "Final Tips" list, because it isn't a closing recipe. These are just the muscle-memory adjustments that turn GORM from a foot-gun into a tool:

Run db.Logger.LogMode(logger.Info) in development, at least at first. The framework will print every query it generates. After a few weeks you'll have an internal model of "what each Go method actually does to the database," and you can dial the logger back down. Without that pass, the mental model never forms.

Treat every *gorm.DB as throwaway. Start from a session for any non-trivial operation. Don't share builders.

Decide between struct and map updates at the team level, write it down, enforce it.

Use Joins instead of Preload when you actually want one query, especially for nested relationships where Preload would generate N+1-shaped behavior (it's not technically N+1, but the multi-roundtrip pattern bites the same way under latency).

Keep hooks small, side-effect-free, and row-scoped. If a hook needs to call out to another system, push that into an outbox or an event.

Don't use AutoMigrate in production. Use a real migration tool. AutoMigrate is a developer-loop convenience, not a deployment strategy: it won't drop columns, it won't rename safely, it has no concept of running on one replica at a time.

So is GORM "good"?

Yes. With caveats.

GORM is good when the database work is mostly CRUD, the team is small, the schema is moving fast, and the cost of shipping the next feature is higher than the cost of one day digging into a slow query. That describes a lot of Go services, especially in their first year.

GORM is not the right fit when your hot paths are analytical, when your team has strong SQL skills and would rather see the query than read the Go that generates it, or when you're shipping into an environment where every extra roundtrip costs you (high-latency networks, edge deployments, very tight latency budgets).

The mistake isn't choosing GORM. The mistake is treating it as invisible. The framework is doing real work for you on every call (generating queries, managing sessions, hooking into your structs), and the only way the trade-off stays favorable is if you keep an eye on what it's actually doing. Turn the logger on. Read the SQL it generates. Be willing to drop down to raw queries the day GORM stops paying.

You don't owe loyalty to your ORM. You owe correctness to your database.