tool

GORM

created 2026-05-05 go · orm · database · postgresql · mysql

GORM

The most popular ORM for Go. Active record-style API with auto-migrations, soft deletes, hooks, and associations. Pragmatic if you want one tool that does everything; opinionated in ways that don’t always reward you.

Why it’s the default

  • Familiar API for anyone coming from Rails/Django/Hibernate: db.First(&user, id), db.Where("active = ?", true).Find(&users).
  • Auto-migration: db.AutoMigrate(&User{}) keeps schema in sync with structs in dev. (Don’t use in prod — see warnings below.)
  • Hooks: BeforeSave, AfterFind, etc. for cross-cutting logic.
  • Associations: HasMany/BelongsTo/ManyToMany handled via struct tags + callbacks.
  • Soft deletes out of the box via gorm.DeletedAt.

The honest case against

GORM is contentious in the Go community. Reasons:

  • Reflection-heavy runtime — slower than raw database/sql and harder to profile.
  • Hidden N+1 traps when associations auto-load.
  • Opaque SQL — debugging requires db.Debug() to see what was actually run.
  • Auto-migration in prod is a foot-gun — silently drops indexes, can lock large tables.
  • Type-safety only at struct level — strings for column names, queries unchecked at compile time.

The increasingly popular alternative is sqlc: write SQL in .sql files, generate type-safe Go from them. You get exact SQL visibility and compile-time guarantees, at the cost of less “magic.” For new kulify Go services with non-trivial queries, sqlc is the better default.

When GORM still wins

  • Greenfield project with simple CRUD and many tables — auto-migration during dev pays off.
  • Team already knows GORM — switching cost not worth it for a side project.
  • The 3-layer pattern in go-layered-architecture confines GORM to the Repository layer, where its rough edges are isolated.

Repository pattern wrapper

GORM’s *gorm.DB should not leak past Repository:

type repository struct {
    db *gorm.DB
}

func (r *repository) GetUser(ctx context.Context, id string) (*User, error) {
    var u User
    if err := r.db.WithContext(ctx).First(&u, "id = ?", id).Error; err != nil {
        return nil, err
    }
    return &u, nil
}

The interface above (type Repository interface { GetUser(...) (*User, error) }) hides GORM entirely — Service depends on the interface, mockery generates a mock against it, no *gorm.DB in tests.

Transaction caveat

The big unsolved problem in go-layered-architecture: a Service that needs to call multiple Repository methods atomically has no clean way to share a *gorm.DB transaction without leaking GORM upward. Workarounds:

  • Pass a tx Tx interface (where Tx is your own type wrapping *gorm.DB)
  • Run multi-step writes inside a single Repository method
  • Use a Unit-of-Work pattern

kulify usage

  • No current kulify project uses GORM. katastar uses raw database/sql or pgx.
  • Likely default for any new kulify Go service that prioritizes velocity over per-query control. For queries-as-code-first, sqlc would be the better pick.
  • go-layered-architecture — where GORM lives (Repository layer only)
  • gin — the typical companion HTTP framework
  • mockery — what makes the Repository-interface boundary actually testable