Skip to content

Repository files navigation

gormreuse

Go Reference CI Codecov License: MIT

Note

This project was written by AI (Claude Code).

A Go linter that detects unsafe *gorm.DB instance reuse after chain methods.

Background

GORM's Traditional API chain methods (Where, Order, etc.) modify internal state. Reusing the same *gorm.DB instance after chain methods can cause query conditions to accumulate unexpectedly.

This issue is documented in GORM's official guide on Method Chaining. While GORM's Generics API (v1.30.0+) provides a safer alternative, many production codebases still use the Traditional API and will continue to maintain it for years. This linter helps catch these bugs in real-world scenarios.

q := db.Where("active = ?", true)
q.Find(&users)  // SELECT * FROM users WHERE active = true
q.Find(&admins) // Bug: Conditions accumulate unexpectedly

Installation & Usage

Using mise (macOS/Linux/Windows)

Recommended. gormreuse is installable directly from GitHub Releases via mise's github backend — no extra registry required, and no Go toolchain needed because the binaries are prebuilt:

mise use -g "github:mpyw/gormreuse"
gormreuse ./...

Or pin it per project in mise.toml:

[tools]
"github:mpyw/gormreuse" = "latest"

Important

The go-based methods below build gormreuse from source, which requires Go 1.25 or later. go.mod pins toolchain go1.27.0, so the default GOTOOLCHAIN=auto builds the linter with Go 1.27 — a Go 1.27 toolchain is what lets it understand Go 1.27 source (generic methods, promoted struct-literal keys). go tool also needs Go 1.24+ on PATH, which is where tool directives were introduced.

Using go tool

# Add to go.mod as a tool dependency
go get -tool github.com/mpyw/gormreuse/cmd/gormreuse@latest

# Run via go tool
go tool gormreuse ./...
go install github.com/mpyw/gormreuse/cmd/gormreuse@latest
gormreuse ./...

Using go vet

Since gormreuse has no custom flags, it can be run via go vet:

go install github.com/mpyw/gormreuse/cmd/gormreuse@latest
go vet -vettool=$(which gormreuse) ./...

Using go run

go run github.com/mpyw/gormreuse/cmd/gormreuse@latest ./...

Caution

To prevent supply chain attacks, pin to a specific version tag instead of @latest in CI/CD pipelines (e.g., @v0.17.0).

Downloading the tarball directly (macOS/Linux/Windows)

No package manager? Grab the archive for your platform from GitHub Releases:

export VERSION=0.0.0
export OS=linux    # or darwin
export ARCH=amd64  # or arm64
export BASE_URL="https://github.com/mpyw/gormreuse/releases/download/v${VERSION}"

# Download the archive and the release's checksum list
curl -LO "${BASE_URL}/gormreuse_${VERSION}_${OS}_${ARCH}.tar.gz"
curl -LO "${BASE_URL}/checksums.txt"

# Verify before installing (use `shasum -a 256 -c` on macOS)
sha256sum --ignore-missing -c checksums.txt

tar xzf "gormreuse_${VERSION}_${OS}_${ARCH}.tar.gz"
sudo mv gormreuse /usr/local/bin/

On Windows, download gormreuse_${VERSION}_windows_${ARCH}.zip and extract gormreuse.exe somewhere on your PATH.

Flags

Flag Default Description
-test true Analyze test files (*_test.go) — built-in driver flag
-fix false Apply suggested fixes automatically — built-in driver flag

Generated files (containing // Code generated ... DO NOT EDIT.) are always excluded and cannot be opted in.

Examples

# Exclude test files from analysis
gormreuse -test=false ./...

# Apply automatic fixes
gormreuse -fix ./...

Automatic Fixes

The -fix flag enables automatic repair of violations using two complementary strategies:

Strategy 1: Reassignment (Creating New Roots)

Adds reassignment for non-finisher method calls, creating a new mutable root at each step:

// Before
q := db.Where("base")
q.Where("a")           // VIOLATION: 1st branch (non-finisher)
q.Where("b").Find(nil) // VIOLATION: 2nd branch (finisher)

// After -fix
q := db.Where("base")
q = q.Where("a")       // ← Reassignment creates new root
q.Where("b").Find(nil) // OK: first branch from new root

When applied:

  • Non-finisher methods (chain builders like Where, Order, Limit)
  • Result is not assigned to a variable
  • Expression used as a statement (not part of a larger expression)

Strategy 2: Session (Making Roots Immutable)

Adds .Session(&gorm.Session{}) to make branch roots immutable when reassignment alone isn't sufficient:

// Before
q := db.Where("base")
q.Where("a").Find(nil) // First branch
q.Where("b").Find(nil) // VIOLATION: second branch

// After -fix
q := db.Where("base").Session(&gorm.Session{})  // ← Immutable root
q.Where("a").Find(nil) // OK: independent chain from immutable root
q.Where("b").Find(nil) // OK: independent chain from immutable root

When applied:

  • A root still has 2+ branches after simulating Phase 1 reassignments
  • Special handling for Phi nodes (conditional branches): adds Session to each incoming edge

Combined Example

Both strategies work together for complex cases:

// Before
q := db.Where("base")
q.Where("a")           // non-finisher
q.Where("b").Find(nil) // finisher
q.Where("c")           // non-finisher
q.Where("d").Find(nil) // finisher

// After -fix (both strategies applied)
q := db.Where("base")
q = q.Where("a").Session(&gorm.Session{})  // Reassignment + Session
q.Where("b").Find(nil)
q = q.Where("c")                            // Reassignment
q.Where("d").Find(nil)

Tip

The fix generator intelligently determines which strategy to apply. Run gormreuse -fix ./... to automatically repair all violations in your codebase.

Detection Model: Mutable Branching

This linter detects when a mutable *gorm.DB branches into multiple code paths. The core concept:

  1. Immutable-returning methods (Session, WithContext, Debug, etc.) return an immutable instance that can branch freely
  2. All other methods on a mutable instance create a branch that consumes the instance
  3. Second branch from the same mutable root is a violation

Method Classification

Important

This linter analyzes the Traditional API (*gorm.DB) only. The Generics API (gorm.G[T], available since v1.30.0) returns different types (Interface[T], ChainInterface[T]) and is automatically excluded from analysis.

Category Methods Description
Immutable-Returning Methods Session, WithContext, Debug, Open, Begin, Transaction Return new immutable instance
All Other Methods Where, Find, Count, Order, etc. Create a branch from receiver

Automatic Pollution Sources

The linter conservatively marks *gorm.DB as polluted in these scenarios:

Operation Description
Interface method call repo.Query(db) - Can't statically analyze
Channel send ch <- db - May be received and used elsewhere
Slice/Map storage []*gorm.DB{db} - May be accessed elsewhere
Interface conversion interface{}(db) - May be extracted via type assertion
Non-pure function call helper(db) - Unless marked with //gormreuse:pure
Struct field access h.db.Find(nil) - Traces back to the stored value

Note: Simple struct literal storage (_ = &S{db: q}) without actual field usage does NOT pollute.

Examples

Safe: Branching from immutable

// Session at end creates immutable - safe to branch multiple times
q := db.Where("active = ?", true).Session(&gorm.Session{})
q.Count(&count)  // OK - first branch from q
q.Find(&users)   // OK - q is immutable, can branch freely

// Each branch from immutable creates independent mutable chains
q := db.Where("base").Session(&gorm.Session{})
q.Where("a").Find(&users1)  // OK - branch 1 (independent chain)
q.Where("b").Find(&users2)  // OK - branch 2 (independent chain)

Violation: Multiple branches from mutable

// Second branch from mutable is a violation
q := db.Where("active = ?", true)  // q is mutable
q.Find(&users)   // first branch from q - OK
q.Count(&count)  // VIOLATION: second branch from q

// Even without "finisher" - any method creates a branch
q := db.Where("x")
q.Where("a")     // first branch from q - OK
q.Where("b")     // VIOLATION: second branch from q

// Session in middle doesn't help - result is still mutable
q := db.Session(&gorm.Session{}).Where("x")  // q is mutable!
q.Find(&users)   // first branch - OK
q.Count(&count)  // VIOLATION: second branch

// Using immutable-returning method on polluted value is also a violation
q := db.Where("x")
q.Find(&users)                       // first branch - OK
q.Session(&gorm.Session{}).Count(&c) // VIOLATION: second branch from q

Important

Chaining without reassignment is a violation! Each statement using the same variable creates a separate branch:

q := db.Where("base")
q.Where("a")           // first branch - OK
q.Where("b")           // VIOLATION: second branch
q.Find(&users)         // VIOLATION: third branch

Solution: Reassign the result or use method chaining in a single expression:

// Option 1: Reassign each step
q := db.Where("base")
q = q.Where("a")
q = q.Where("b")
q.Find(&users)         // OK - first branch from final q

// Option 2: Single chained expression
db.Where("base").Where("a").Where("b").Find(&users)  // OK - single chain

Scopes / Preload callbacks

The *gorm.DB passed to a Scopes / Preload callback is a mid-chain (mutable) value, so reusing it inside the callback is a violation — just like any other mutable value:

db.Scopes(func(tx *gorm.DB) *gorm.DB {
    tx.Where("a").Find(&users) // first branch from tx - OK
    return tx.Where("b")       // VIOLATION: second branch from tx
})

Parameters are mutable

A *gorm.DB parameter is a mutable root: a caller may pass a mid-chain value, so branching a parameter interferes exactly as branching a local would.

func helper(tx *gorm.DB) *gorm.DB {
    tx.Where("a").Find(&users) // first branch from tx - OK
    return tx.Where("b")       // VIOLATION: second branch from tx
}

To opt a helper out — declaring that the caller is responsible for passing an isolated value — annotate it with //gormreuse:immutable-param.

Reuse safety is determined by GORM's internal clone field: a handle with clone > 0 forks a fresh Statement on the next chain call (safe to reuse), while a mid-chain handle (clone == 0) shares its Statement. A parameter's clone value is statically unknowable, so it is treated conservatively as mutable.

Source clone Reusable?
gorm.Open, Session, WithContext, Debug > 0 ✅ forks a fresh Statement
Begin result, Transaction callback tx > 0 ✅ fresh transaction handle
Mid-chain (Where, Order, …) == 0 ❌ shares Statement
A *gorm.DB parameter unknown ❌ treated as mutable

Transaction/Connection/FindInBatches callbacks (e.g. tx in db.Transaction(func(tx *gorm.DB) error {...})) are exempt — their tx is a fresh forkable handle. Declare your own such helpers with //gormreuse:immutable-input(name).

Safe: Variable reassignment

Variable reassignment creates a new mutable root, so the variable can be used fresh:

q := db.Where("x")
q.Find(&users)        // first branch from original q - OK

q = db.Where("y")     // reassignment creates NEW mutable root
q.Find(&admins)       // first branch from new q - OK

q = db.Where("z")     // another reassignment
q.Count(&count)       // first branch from newest q - OK

This is safe because internally, the linter uses SSA (Static Single Assignment) form where each assignment creates a distinct value. The new value has no relationship to the previous one.

// Reassignment in loops is also safe
for _, filter := range filters {
    q := db.Where(filter)  // new mutable root each iteration
    q.Find(&results)       // OK - first branch in this iteration
}

// Conditional reassignment
q := db.Where("base")
if condition {
    q = db.Where("alt")    // reassignment on this path
}
q.Find(&users)             // OK - first branch from whichever root

Directives

  • Directives can be combined with commas: //gormreuse:pure,immutable-return, //gormreuse:pure,immutable-param
  • Trailing comments use //: //gormreuse:ignore // reason here

//gormreuse:ignore

Suppress warnings for a specific line:

q := db.Where("active = ?", true)
q.Find(&users)
//gormreuse:ignore // intentional reuse for pagination
q.Count(&count)  // Suppressed

Or suppress for an entire function:

//gormreuse:ignore
func legacyCode(db *gorm.DB) {
    // All violations in this function are suppressed
}

Or suppress for an entire file (place before package declaration):

//gormreuse:ignore

package mypackage

// All violations in this file are suppressed

Warning

Unused //gormreuse:ignore directives are reported as warnings for line-level and function-level ignores. This helps keep the codebase clean by identifying stale ignore comments. File-level ignores do not trigger unused warnings.

//gormreuse:pure

Mark a function or closure as not polluting its *gorm.DB argument:

//gormreuse:pure
func withTenant(db *gorm.DB, tenantID int) *gorm.DB {
    return db.Session(&gorm.Session{}).Where("tenant_id = ?", tenantID)
}

// Also works on closures (next-line or same-line pattern):
//gormreuse:pure
helper := func(q *gorm.DB) { _ = q }

helper2 := func(q *gorm.DB) { //gormreuse:pure
    _ = q
}

Tip

All user-defined functions/methods that accept or return *gorm.DB are treated as polluting by default. You must add //gormreuse:pure to any helper function that safely wraps *gorm.DB without polluting it.

Warning

The linter validates that functions marked //gormreuse:pure actually satisfy the pure contract:

//gormreuse:pure
func badPure(db *gorm.DB) {
    db.Where("x")  // ERROR: pure function pollutes *gorm.DB argument by calling Where
}

Valid pure functions must:

  • NOT call polluting methods (Where, Find, etc.) directly on *gorm.DB arguments
  • MAY call polluting methods on immutable values (e.g., db.Session(&gorm.Session{}).Where(...) is OK)

Note: Pure functions MAY return mutable *gorm.DB. Callers must treat the return value as potentially mutable:

q := withTenant(db, 1)  // q is mutable!
q.Find(&users)          // first branch - OK
q.Count(&count)         // VIOLATION - second branch from mutable q

//gormreuse:immutable-return

Mark a function as returning an immutable *gorm.DB (like builtin Session, WithContext):

//gormreuse:immutable-return
func GetDB() *gorm.DB {
    return globalDB.Session(&gorm.Session{})
}

func useIt() {
    db := GetDB()
    db.Where("x").Find(&users)
    db.Where("y").Find(&admins) // OK - GetDB returns immutable
}

Tip

Use this directive for DB connection helpers that return a fresh, immutable *gorm.DB instance. This allows callers to reuse the returned value freely without worrying about pollution.

Warning

Body contract. If the function actually returns a mutable value, the directive is reported at the declaration — otherwise the linter would trust it and silently allow unsafe reuse of the return value at every call site:

//gormreuse:immutable-return // VIOLATION: returns mutable *gorm.DB
func GetDB() *gorm.DB {
    return globalDB.Session(&gorm.Session{}).Where("x") // trailing Where re-forks a fresh clone==0 Statement
}

Put Session at the end of the chain (globalDB.Where("x").Session(&gorm.Session{})) so the returned value is isolated. Only provably-mutable returns (whose root is a gorm chain method like Where) are reported; a bare parameter or a value from an unmarked helper the linter can't see through is given the benefit of the doubt.

//gormreuse:immutable-param

Opt a function's *gorm.DB parameters out of the default mutable treatment — they are treated as immutable inside the function, so the caller becomes responsible for passing an isolated value:

//gormreuse:immutable-param
func applyFilters(tx *gorm.DB) {
    tx.Where("a").Find(&users) // OK - tx is treated as immutable here
    tx.Where("b").Find(&more)  // OK
}

Warning

Caller-side contract. When the function actually branches such a parameter, passing a mutable *gorm.DB at a call site is reported — the callee's internal branching would interfere because the value is not isolated:

func caller(root *gorm.DB) {
    q := root.Session(&gorm.Session{}).Where("x") // mutable
    applyFilters(q) // VIOLATION: mutable *gorm.DB passed to immutable-param parameter
}

Fix by passing an isolated value (root.Session(&gorm.Session{})), or make the caller //gormreuse:immutable-param too so the contract propagates.

Tip

Combine with //gormreuse:pure when a helper both leaves its argument unpolluted and expects an isolated value. A redundant //gormreuse:immutable-param — one whose parameter is never reused, so it suppresses nothing — is reported so you can drop it.

//gormreuse:immutable-input(name)

Declare that the function passes an immutable *gorm.DB to its callback parameter name — a user-defined equivalent of gorm's Transaction/Connection/FindInBatches. The named callback's *gorm.DB parameter is then treated as immutable, so reuse inside the callback is allowed:

//gormreuse:immutable-input(cb)
func withFreshDB(cb func(*gorm.DB) error, db *gorm.DB) error {
    return cb(db.Session(&gorm.Session{})) // hands the callback an isolated value
}

func useIt(db *gorm.DB) {
    _ = withFreshDB(func(q *gorm.DB) error {
        q.Find(&users)
        q.Count(&count) // OK — q is declared immutable input
        return nil
    }, db)
}

Warning

Body contract. If the function actually hands the callback a mutable value, it is reported:

//gormreuse:immutable-input(cb)
func bad(cb func(*gorm.DB) error, db *gorm.DB) error {
    q := db.Where("x")
    return cb(q) // VIOLATION: immutable-input(cb) declared but mutable *gorm.DB passed to callback
}

The directive is reported unused when name isn't a parameter, isn't a function type, or the callback has no *gorm.DB parameter.

Note

gorm's built-in Transaction, Connection, and FindInBatches already pass a fresh handle to their callbacks, so reuse inside those callbacks needs no directive.

//gormreuse:pure,immutable-return

The recommended pattern for DB connection helpers - combines both guarantees:

//gormreuse:pure,immutable-return
func GetTenantDB(db *gorm.DB, tenantID int) *gorm.DB {
    return db.Session(&gorm.Session{}).Where("tenant_id = ?", tenantID)
}

func useIt(db *gorm.DB) {
    tenant1 := GetTenantDB(db, 1)
    tenant2 := GetTenantDB(db, 2)
    tenant1.Where("x").Find(&users)   // OK
    tenant2.Where("y").Find(&admins)  // OK
    tenant1.Count(&count)             // OK - immutable-return
    db.Find(&all)                     // OK - pure (db not polluted)
}

Warning

Unused //gormreuse:pure, //gormreuse:immutable-return, and //gormreuse:immutable-param directives are reported as warnings (a directive whose signature doesn't fit — e.g. immutable-param on a function with no *gorm.DB parameter). A redundant //gormreuse:immutable-param (signature-valid but its parameter is never reused) is reported too. For combined directives like //gormreuse:pure,immutable-return, if either part is used, no unused warning is reported.

Temporary rule: Session/WithContext/Debug inside Scopes callbacks

Warning

This is a temporary rule working around an upstream GORM bug.

Calling Session, WithContext, or Debug inside a Scopes callback corrupts GORM's InstanceSet/InstanceGet bookkeeping and leaks transactions (go-gorm/gorm#7592, fix attempt #7593):

db.Scopes(func(q *gorm.DB) *gorm.DB {
    return q.Session(&gorm.Session{}).Where("x") // WARNING: Session() in Scopes callback causes transaction leak
})

Preload callbacks are unaffected (Preload builds a fresh DB), so the same code inside a Preload callback is not flagged.

Removal condition: once the upstream fix ships in a supported GORM release, this rule is deleted by removing internal/scopes_session_warning.go and its single call site in RunSSA — the file documents this in-line. Re-check go-gorm/gorm#7592 on each GORM version bump.

Documentation

  • CLAUDE.md - AI assistant guidance for development

Development

# Run tests
go test ./...

# Build CLI
go build -o bin/gormreuse ./cmd/gormreuse

# Run linter on a project
./bin/gormreuse ./...

Related Tools

License

MIT License

About

Go linter that detects unsafe *gorm.DB instance reuse

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages