Skip to content

GORM (Go) ​

A reflection-based, struct-tag-driven active-record ORM for Go: models are plain structs annotated with gorm:"..." tags, queries are a chainable *gorm.DB builder that a runtime Statement + clause AST assembles, and a callbacks pipeline lowers each terminal call to SQL run synchronously over database/sql.

FieldValue
LanguageGo (module gorm.io/gorm, go 1.18)
LicenseMIT (LICENSE; "Copyright (c) 2013-present Jinzhu")
Repositorygo-gorm/gorm
Documentationgorm.io · pkg.go.dev/gorm.io/gorm
CategoryFull ORM (active-record) — reflection-based, code-first; no identity map / implicit unit of work
Abstraction levelFull-ORM rung, but stops short of change-tracking-on-flush — each finisher runs its own SQL
Query modelChainable fluent builder + struct tags + raw string with ? params → a runtime Statement/clause AST
Effect/async modelBlocking (synchronous database/sql); errors accumulate on the db.Error field, never thrown, never a Future
BackendsPostgreSQL, MySQL/MariaDB, SQLite, SQL Server (+ GaussDB) via gorm.io/driver/*
First release≈2013 (the original GORM; LICENSE copyright "2013-present"), gorm.io/gorm v2 rewrite 2020 (web-attested)
Latest versionv1.31.x — the pinned tree 1d6ce99 (2026-06-22) carries the v1.31.2 tag

NOTE

GORM is this survey's data point for the mainstream reflection-driven active-record ORM — the Go counterpart to ActiveRecord (Ruby) and Django ORM. It occupies the full-ORM rung: you declare structs, mutate them, and call db.Create(&user) / db.Save(&user). But it is the low-ceremony end of that rung — queries are runtime strings and reflected structs, not compile-time-checked (contrast sqlc, ent, and Rust's Diesel); effects are blocking, not an IO/Effect value; and there is no identity map or unit of work. Terms below link to concepts.


Overview ​

What it solves ​

GORM maps Go structs to relational tables and back, so an application manipulates typed Go values instead of hand-writing SQL and scanning rows by index. The package docstring states the ambition plainly (gorm.go):

"Package gorm is a full-featured, developer-friendly ORM for Golang."

and the README opens with the tagline the project is known by (README.md):

"The fantastic ORM library for Golang, aims to be developer friendly."

It sits at the full-ORM active-record rung: a model both carries data and is the thing you persist. db.Create(&user) inserts a row and writes the generated primary key back into user; db.First(&user, 10) loads it; db.Save(&user) / db.Model(&user).Update("name", "jinzhu") write mutations. The README's feature list is a fair census of the surface (README.md):

"Full-Featured ORM / Associations (Has One, Has Many, Belongs To, Many To Many, Polymorphism, Single-table inheritance) / Hooks (Before/After Create/Save/Update/Delete/Find) / Eager loading with Preload, Joins / Transactions, Nested Transactions, Save Point, RollbackTo to Saved Point / ... / Auto Migrations / ... / Developer Friendly"

Design philosophy ​

GORM is code-first and reflection-driven. A model is an ordinary Go struct; its schema (table name, columns, keys, indexes, relations) is reflected at runtime from the struct's fields and gorm:"..." struct tags — there is no schema file and no code generation step in the core. The base model GORM ships to embed is itself just a struct (model.go):

go
// gorm: model.go
type Model struct {
    ID        uint `gorm:"primarykey"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt DeletedAt `gorm:"index"`
}

Field parsing reads the tag straight off the reflected struct field (schema/field.go):

go
// gorm: schema/field.go — ParseField
tagSetting = ParseTagSetting(fieldStruct.Tag.Get("gorm"), ";")
// …
PrimaryKey:    utils.CheckTruth(tagSetting["PRIMARYKEY"], tagSetting["PRIMARY_KEY"]),
AutoIncrement: utils.CheckTruth(tagSetting["AUTOINCREMENT"]),

Everything downstream — the SQL builder, the migrator, row hydration — works off the resulting schema.Schema / schema.Field reflection metadata, cached per model type. The consequence, developed throughout this page, is that GORM's power and its weaknesses share one root: the query and the schema are runtime values built by reflection and string assembly, not types the Go compiler checks.

Connection, pooling & resource lifetime ​

You obtain a *gorm.DB by handing gorm.Open a Dialector (a driver adapter) and options (gorm.go):

go
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})

The returned *DB embeds a *Config, an Error, a RowsAffected, and a *Statement (gorm.go). Underneath, GORM does not implement its own driver or pool — it drives Go's standard database/sql. The connection abstraction is ConnPool, whose method set is exactly the database/sql context API (interfaces.go):

go
// gorm: interfaces.go
type ConnPool interface {
    PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
    ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
    QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
    QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
}

db.DB() returns the underlying *sql.DB (gorm.go) — and a *sql.DB is a connection pool with its own sizing (SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime), managed by the standard library, not GORM. Resource lifetime is therefore the database/sql model: the pool leases a connection per statement and returns it when the call completes; there is no scoped acquire/release effect. Open pings the pool on connect unless DisableAutomaticPing is set (gorm.go).

Two lifetime knobs sit above the pool. db.Session(&Session{...}) forks a configured, cheap clone of the *DB for a scope (context, logger, batch size, prepared-statement mode) (gorm.go); db.Connection(func(tx *gorm.DB) error {...}) leases a single *sql.Conn for a block and returns it to the pool afterward (finisher_api.go). PrepareStmt mode wraps the pool in a PreparedStmtDB that caches prepared statements with an LRU (PrepareStmtMaxSize / PrepareStmtTTL) (gorm.go) — plan reuse plus the safety of out-of-band parameters (below).

Query construction & injection safety ​

This is GORM's centre of gravity, and the section the survey weighs most for safety.

The query is a chainable *gorm.DB. Chain methods (Where, Select, Joins, Order, Group, Having, Limit, Offset, Preload, Distinct, …) live in chainable_api.go; terminal "finisher" methods (Create, First, Find, Save, Update, Delete, Count, Scan, …) live in finisher_api.go. Each chain method clones the statement (getInstance), mutates tx.Statement, and returns the *DB, so calls thread left to right — the canonical example from the Where docstring (chainable_api.go):

go
// gorm: chainable_api.go — Where docstring
db.Where("name = ?", "jinzhu").First(&user)
db.Where(&User{Name: "jinzhu", Age: 20}).First(&user)
db.Where("name = ?", "jinzhu").Where("age <> ?", "20").First(&user)

Where funnels its argument through Statement.BuildCondition, which returns []clause.Expression and adds a clause.Where clause (chainable_api.go):

go
// gorm: chainable_api.go
func (db *DB) Where(query interface{}, args ...interface{}) (tx *DB) {
    tx = db.getInstance()
    if conds := tx.Statement.BuildCondition(query, args...); len(conds) > 0 {
        tx.Statement.AddClause(clause.Where{Exprs: conds})
    }
    return
}

A Statement accumulates clauses and bind vars; a clause AST renders them. The Statement holds Clauses map[string]clause.Clause, an SQL strings.Builder, and Vars []interface{} (statement.go); the clause package is the query AST — clause.Where, clause.Expr, and the typed predicate nodes clause.Eq / Neq / Gt / IN / Like (clause/expression.go). Nothing touches the database while the chain is built; the finisher hands the assembled statement to the callback pipeline (below) which calls Statement.Build(clauses...) to emit SQL in a fixed clause order.

Values enter only as bound parameters — the ? placeholder is the safety seam. A clause.Expr{SQL, Vars} wraps a raw SQL fragment and its arguments; its Build walks the fragment byte-by-byte, and every ? consumes one argument, routed through builder.AddVar rather than spliced into the text (clause/expression.go):

go
// gorm: clause/expression.go — Expr.Build (abridged)
for _, v := range []byte(expr.SQL) {
    if v == '?' && len(expr.Vars) > idx {
        if afterParenthesis || expr.WithoutParentheses {
            processValue(builder, expr.Vars[idx])
        } else {
            builder.AddVar(builder, expr.Vars[idx])
        }
        idx++
    } else {
        // … copy the byte verbatim …
        builder.WriteByte(v)
    }
}

AddVar is where a value becomes a parameter: it appends the value to stmt.Vars and writes the driver's placeholder via the dialector's BindVarTo (e.g. ? for MySQL/SQLite, $1…$n for Postgres) — the SQL text and the data leave on separate channels (statement.go):

go
// gorm: statement.go — AddVar, default arm
default:
    stmt.Vars = append(stmt.Vars, v)
    stmt.DB.Dialector.BindVarTo(writer, stmt, v)

Because the argument is never rendered into the SQL string, it reaches the server as a prepared-statement parameter and cannot change the query's structure — SQL injection for values is structurally impossible, the same guarantee the lower-level libraries in this survey provide. The typed predicate nodes preserve it: clause.Eq{Column, Value}.Build writes the quoted column then builder.AddVar(builder, eq.Value), and even lowers eq.Value == nil to IS NULL (clause/expression.go).

BuildCondition is polymorphic, which is GORM's ergonomic hook and a footgun (statement.go): a string with ? args becomes a clause.Expr; a string with @name args becomes a clause.NamedExpr; a struct becomes one clause.Eq per non-zero field (so Where(&User{Age: 0}) silently drops Age — the classic zero-value trap); a map[string]interface{} becomes an Eq/IN per key (maps include zero values). A bare string or number is treated as a primary-key lookup.

The escape hatches stay parameterized — but identifiers do not. db.Raw(sql, values...) and db.Exec(sql, values...) route through the same clause.Expr/NamedExpr machinery, so their ?/@name arguments are bound, not spliced (finisher_api.go, chainable_api.go); gorm.Expr(sql, args...) injects a raw SQL fragment as a value (gorm.go). The genuine danger is identifiers and raw fragments, which cannot be bind parameters: Order, Group, and raw Select column strings are marked Raw: true and substituted textually (chainable_api.go), so passing user-controlled text as a column/order expression re-opens injection. GORM's own logger underlines that materializing SQL with values inlined is unsafe (logger/sql.go):

"ExplainSQL generate SQL string with given parameters, the generated SQL is expected to be used in logger, execute it might introduce a SQL injection vulnerability"

The upstream guidance (the gorm.io security page, web) is the standard one: keep user data in ?/@name parameters and never string-concatenate it into a condition, table, or column name.

Schema, migrations & code generation ​

GORM is code-first: the annotated structs are the schema, reflected at runtime (see Design philosophy). A representative model shows the tag language carrying columns, keys, and relations at once (utils/tests/models.go):

go
// gorm: utils/tests/models.go
type User struct {
    gorm.Model
    Name      string
    Age       uint
    Toys      []Toy      `gorm:"polymorphic:Owner"`
    CompanyID *int
    Company   Company
    Languages []Language `gorm:"many2many:UserSpeak;"`
    Team      []User     `gorm:"foreignkey:ManagerID"`
}

Tag settings drive column type, size, nullability, defaults, uniqueness, indexes, and the relationship graph (foreignKey, references, many2many, polymorphic) — all parsed into schema.Field.TagSettings (schema/field.go).

AutoMigrate reflects structs into CREATE/ALTER (migrator/migrator.go). For each model it creates the table if absent, else reconciles columns:

go
// gorm: migrator/migrator.go — AutoMigrate (abridged)
if foundColumn == nil {
    // not found, add column
    if err = execTx.Migrator().AddColumn(value, dbName); err != nil {
        return err
    }
} else {
    // found, smartly migrate
    field := stmt.Schema.FieldsByDBName[dbName]
    if err = execTx.Migrator().MigrateColumn(value, field, foundColumn); err != nil {
        return err
    }
}

It then adds any missing constraints and indexes. The critical property — and a finding — is that AutoMigrate is additive only: it adds and widens columns/indexes/constraints but has no branch that drops a column, index, or table that has disappeared from the struct. GORM's docs state this directly ("AutoMigrate will … but WON'T delete unused columns to protect your data"). So it is a convenience for keeping a dev/prod schema roughly in step, not a full migration system: there is no versioned migration runner, no bookkeeping table, and no down/rollback path in the core (contrast SeaORM's sea-orm-migration, Ecto's migrations, Diesel's SQL migration files). Production projects are steered to a dedicated migration tool for destructive or ordered changes.

Code generation is out of core. The core reflects at runtime and generates nothing; the sibling project gorm.io/gen (Gen) does DAO / type-safe query codegen from models or a live database — GORM's answer to the codegen'd, type-checked Go alternatives sqlc and ent. A newer, in-core generics surface, gorm.G[T], wraps the same reflection engine in Go 1.18 type parameters (generics.go) so gorm.G[User](db).Where(...).Find(ctx) returns a typed []User and takes an explicit context.Context — but it type-checks the result shape, not the SQL: a wrong column name or a malformed condition string is still a runtime error.

Type mapping & result decoding ​

Row hydration is reflection. After a query runs, gorm.Scan / ScanRows reflects the destination (*struct, *[]struct, *map, or a primitive slice for Pluck) and sets fields by matching result columns to schema.Fields (finisher_api.go, scan.go). Each schema.Field carries closures — ValueOf, ReflectValueOf, Set — that read and write the field through reflection (schema/field.go), plus an optional Serializer for custom on-the-wire encodings (JSON, gob, unixtime).

Codecs bottom out in database/sql. A Go value binds through the standard driver.Valuer and scans back through sql.Scanner; GORM adds its own Valuer (GormValue(ctx, db) clause.Expr) for values that must render as a SQL expression (interfaces.go). Column SQL types come from the dialector's DataTypeOf plus GormDBDataType overrides on a type (migrator/migrator.go).

Nullability is expressed the Go way: a pointer field (*int, *time.Time) or a sql.Null* wrapper is nullable; a plain value is not. GORM's soft-delete column is exactly such a wrapper — type DeletedAt sql.NullTime with Scan/Value/JSON methods (soft_delete.go) — and a DeletedAt field silently adds WHERE deleted_at IS NULL to every query and turns Delete into an UPDATE … SET deleted_at (a global scope you opt out of with db.Unscoped()). MapColumns remaps result column names onto struct fields for ad-hoc projections (chainable_api.go).

Effect model, transactions & error handling ​

This is the dimension the survey weights most heavily, and where GORM's choices are the most classical.

Blocking, synchronous, on database/sql. Every finisher runs to completion on the calling goroutine and returns a *DB; there is no Future, Task, or IO/Effect value (blocking in the survey's taxonomy). Concurrency is Go's own — you run queries in goroutines — not a composed effect.

Errors are a mutable field, not a return value or an exception. Go has no exceptions, and GORM does not follow the idiomatic (T, error) return either: it accumulates errors on db.Error, chaining multiple with %w (gorm.go):

go
// gorm: gorm.go — AddError
if db.Error == nil {
    db.Error = err
} else {
    db.Error = fmt.Errorf("%v; %w", db.Error, err)
}

so the idiom is if err := db.Where(...).First(&u).Error; err != nil { … } — you inspect the field after the chain, and RowsAffected beside it. The failure vocabulary is a set of package-level sentinels (errors.go): ErrRecordNotFound, ErrDuplicatedKey, ErrForeignKeyViolated, ErrCheckConstraintViolated, ErrInvalidTransaction, ErrMissingWhereClause, ErrPrimaryKeyRequired, … Mapping a raw driver error onto the portable ErrDuplicatedKey/ErrForeignKeyViolated sentinels is opt-in, gated on the TranslateError config and a dialector that implements ErrorTranslator (gorm.go, interfaces.go). This is the survey-relevant contrast: GORM has a coarse, mutable, exception-free error posture — neither the thrown SQLException of the JDBC world nor the encoded per-query error channel of the functional mappers, and no isRetryable modelling.

The callback pipeline is the executor and the plugin seam. A finisher does not build SQL itself; it invokes a named, ordered chain of callbacks. The six processors are set up at Open (callbacks.go), and the defaults register the pipeline stages (callbacks/callbacks.go):

go
// gorm: callbacks/callbacks.go — the create pipeline
createCallback.Match(enableTransaction).Register("gorm:begin_transaction", BeginTransaction)
createCallback.Register("gorm:before_create", BeforeCreate)
createCallback.Register("gorm:save_before_associations", SaveBeforeAssociations(true))
createCallback.Register("gorm:create", Create(config))
createCallback.Register("gorm:save_after_associations", SaveAfterAssociations(true))
createCallback.Register("gorm:after_create", AfterCreate)
createCallback.Match(enableTransaction).Register("gorm:commit_or_rollback_transaction", CommitOrRollbackTransaction)

db.Create(&user) is literally tx.callbacks.Create().Execute(tx) (finisher_api.go); Query/Update/Delete/Row/Raw each have their own ordered processor. Plugins and hooks hang off this seam — db.Callback().Create().Before("gorm:create").Register("my:audit", fn) inserts a stage, with Before/After/Remove/Replace and a topological sort resolving order (callbacks.go). This is how the README's "flexible plugin API" (DB resolver, Prometheus, tracing) is built.

Active-record persistence — mutation, but no unit of work. GORM mutates the struct you hand it. After an INSERT, the create callback reads result.LastInsertId() and writes the auto-increment PK back into the struct's primary-key field (callbacks/create.go); Save inserts when the PK is blank and updates otherwise (finisher_api.go):

go
// gorm: finisher_api.go — Save (struct arm, abridged)
for _, pf := range tx.Statement.Schema.PrimaryFields {
    if _, isZero := pf.ValueOf(tx.Statement.Context, reflectValue); isZero {
        return tx.callbacks.Create().Execute(tx)   // blank PK → INSERT
    }
}
// … otherwise fall through to UPDATE

Per-record lifecycle hooks fire from inside the callbacks if the model implements the interface — BeforeCreate, AfterCreate, BeforeSave, BeforeUpdate, AfterUpdate, BeforeDelete, AfterDelete, AfterFind (callbacks/interfaces.go) — each func(*gorm.DB) error. But this is active-record persistence without Fowler's heavier machinery, and that absence is a finding: there is no identity map (two db.First(&a) / db.First(&b) of the same row yield two independent structs), no session-level change tracking, and no unit of work that batches a diff and flushes it. Statement.Changed computes changed columns per update call against the passed value (statement.go), not against a tracked snapshot; every finisher issues its own SQL immediately. Batching multiple entities is manual — you write them in a loop inside a transaction, or use FullSaveAssociations / association mode.

Transactions: closure or manual, nested via SAVEPOINT. The block form commits on a nil return and rolls back on error or panic (finisher_api.go):

go
// gorm: finisher_api.go — Transaction (block form)
db.Transaction(func(tx *gorm.DB) error {
    if err := tx.Create(&user).Error; err != nil {
        return err   // rolls back
    }
    return nil       // commits
})

Manual control is db.Begin(opts...) / Commit() / Rollback(), and nesting is real: calling Transaction (or Begin) inside an open transaction opens a savepoint rather than a new connection (finisher_api.go):

go
// gorm: finisher_api.go — Transaction, nested arm
spID := new(maphash.Hash).Sum64()
err = db.SavePoint(fmt.Sprintf("sp%d", spID)).Error
// … defer RollbackTo(sp…) on panic or error …

SavePoint(name) / RollbackTo(name) are exposed directly and dispatch to the dialector's SavePointerDialectorInterface (finisher_api.go, interfaces.go); DisableNestedTransaction turns nesting off. Isolation level and read-only mode ride on the standard *sql.TxOptions passed to Begin. Notably, GORM wraps every single write in a transaction by default for integrity, an overhead you opt out of (gorm.go):

"GORM perform single create, update, delete operations in transactions by default to ensure database data integrity. You can disable it by setting SkipDefaultTransaction to true"

Associations and eager loading — the N+1 story. GORM loads relations two ways. Joins("Account") folds a belongs-to/has-one into the parent query as a SQL JOIN (chainable_api.go). Preload("Orders", conds...) is explicit eager loading by separate query (chainable_api.go): after the parent rows land, the preload callback collects the parents' keys and issues one WHERE … IN (…) query per relation, then stitches the children back by an in-memory identity map (callbacks/preload.go). So Preload mitigates the N+1 problem — N parents cost one extra round-trip per relation hop, not N — but it is a second query, not a join, and it is opt-in: forget the Preload/Joins and accessing a relation simply yields the zero value, since GORM has no lazy-loading proxy. Explicit loading is the price and the safeguard.

Ecosystem & maturity ​

GORM is the most widely used Go ORM. It is MIT-licensed and authored by Jinzhu (Jinzhu Zhang) (LICENSE); the current module gorm.io/gorm is the v2 rewrite (2020) of the original 2013 library, targeting go 1.18. Database support is a family of out-of-tree dialector drivers — the test module pins gorm.io/driver/{mysql,postgres,sqlite,sqlserver,gaussdb} (tests/go.mod) — plus community drivers (ClickHouse, TiDB, …). The surrounding ecosystem is substantial: gorm.io/gen (query/DAO codegen), and first-party plugins under gorm.io/plugin/* — dbresolver (read/write splitting, multiple databases), prometheus, and OpenTelemetry tracing — all built on the callback seam. The README positions it as "Full-Featured ORM … Every feature comes with tests" (README.md), and its GitHub adoption (tens of thousands of stars, a very large dependent graph) is web-verifiable. The pinned tree 1d6ce99 (2026-06-22) carries the v1.31.2 tag.

Strengths ​

  • Low-friction developer experience. Plain structs + gorm:"..." tags, a chainable API that reads like the query, and active-record finishers (Create/First/Save) that mutate your struct — minimal boilerplate to first query (README.md).
  • Injection-safe for values by default. ?/@name arguments always become bound parameters via AddVar + the dialector's BindVarTo; SQL and data travel on separate channels (clause/expression.go, statement.go).
  • One reflection engine, many databases. The same models and queries render to Postgres, MySQL, SQLite, or SQL Server through pluggable dialectors (interfaces.go).
  • Rich relational features. Associations (has-one/many, belongs-to, many-to-many, polymorphic), Preload/Joins eager loading with N+1 avoidance, and per-record hooks (callbacks/preload.go, callbacks/interfaces.go).
  • Extensible callback pipeline. Create/query/update/delete are ordered, named callbacks you can insert into, replace, or remove — the basis for the plugin ecosystem (callbacks.go).
  • Real nested transactions and savepoints, plus a default transaction wrapper for single writes (finisher_api.go).
  • AutoMigrate keeps a schema roughly in step with structs without writing DDL for the additive common case (migrator/migrator.go).

Weaknesses ​

  • No compile-time query checking. Conditions, column names, and raw SQL are runtime strings and reflected structs; a typo or shape mismatch is a runtime db.Error, not a build error — the opposite of sqlc/ent/Diesel (statement.go).
  • interface{}-heavy, polymorphic API. Where/BuildCondition accept string, struct, map, or *DB; the struct arm silently drops zero-value fields (the zero-value trap), and identifiers passed to Order/Group/raw Select are substituted textually — an injection surface if fed user input (statement.go, chainable_api.go).
  • No identity map / unit of work / change tracking on a session. Persistence is per-finisher SQL; multi-entity batching and minimal-diff flush are manual (a finding vs. Hibernate/EF Core) (finisher_api.go).
  • Errors on a mutable field, coarse sentinels. db.Error accumulates; driver-error classification is opt-in (TranslateError), and there is no per-query typed error channel or retryability model (gorm.go, errors.go).
  • AutoMigrate is additive-only. It never drops columns/indexes/tables, so it cannot express a destructive or ordered migration; there is no versioned runner or down path in core (migrator/migrator.go).
  • Reflection cost and "magic". Behaviour flows through cached reflection + a callback graph; understanding a surprising query or a soft-delete scope means tracing the pipeline, not reading a call.
  • Blocking only. No effect value, no async surface, no typed error/effect the compiler tracks — the effect-first guarantees this survey chases are absent.

Key design decisions and trade-offs ​

DecisionRationaleTrade-off
Reflect structs + gorm:"..." tags into schema at runtimeCode-first, zero boilerplate, no build step; one model drives queries, migration, hydrationNo compile-time schema/query checking; reflection cost; behaviour is runtime "magic" (schema/field.go)
Chainable *gorm.DB over a Statement + clause ASTReads like SQL; conditions compose and branch from runtime inputThe chain is untyped interface{}; mistakes surface at runtime, not compile time (chainable_api.go)
?/@name args always bound via AddVar / BindVarToInjection-safe for values by construction; multi-dialect placeholdersIdentifiers/raw fragments (Order, Group, raw Select) are textual — a residual injection surface (statement.go)
Active-record finishers mutate the struct (fill PK, Save)Familiar, low-ceremony persistence; the object is the unit of workNo identity map/unit of work; each call runs its own SQL; batching is manual (finisher_api.go)
Errors accumulate on db.Error, exception-freeFits Go's no-exceptions model; chain then check onceCoarse, mutable; opt-in translation; no per-query typed error or retryability (gorm.go, errors.go)
Blocking on database/sql, no effect/async valueSimple, idiomatic Go; standard pool, prepared statements, drivers for freeNo composed effect, no typed error/effect channel; concurrency is manual goroutines (interfaces.go)
Ordered, named callback pipeline as executor + plugin seamUniform create/query/update/delete; hooks and plugins insert cleanlyIndirection: the SQL for an operation is spread across callback stages, harder to trace (callbacks.go)
Preload = explicit eager loading via separate WHERE IN queryAvoids N+1; predictable, no lazy-proxy surprisesOpt-in — a forgotten Preload yields zero relations; a second round-trip, not a join (callbacks/preload.go)
AutoMigrate additive-onlySafe by default: never destroys dataCannot drop/rename or order changes; no versioned migration runner in core (migrator/migrator.go)

Sources ​