Note
This project was written by AI (Claude Code).
A Go linter that detects unsafe *gorm.DB instance reuse after chain methods.
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
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 ./...Using go install
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.
| 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.
# Exclude test files from analysis
gormreuse -test=false ./...
# Apply automatic fixes
gormreuse -fix ./...The -fix flag enables automatic repair of violations using two complementary strategies:
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 rootWhen 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)
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 rootWhen 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
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.
This linter detects when a mutable *gorm.DB branches into multiple code paths. The core concept:
- Immutable-returning methods (
Session,WithContext,Debug, etc.) return an immutable instance that can branch freely - All other methods on a mutable instance create a branch that consumes the instance
- Second branch from the same mutable root is a violation
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 |
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.
// 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)// 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 qImportant
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 branchSolution: 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 chainThe *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
})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).
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 - OKThis 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 can be combined with commas:
//gormreuse:pure,immutable-return,//gormreuse:pure,immutable-param - Trailing comments use
//://gormreuse:ignore // reason here
Suppress warnings for a specific line:
q := db.Where("active = ?", true)
q.Find(&users)
//gormreuse:ignore // intentional reuse for pagination
q.Count(&count) // SuppressedOr 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 suppressedWarning
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.
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.DBarguments - 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 qMark 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.
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.
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.
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.
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.
- CLAUDE.md - AI assistant guidance for development
# Run tests
go test ./...
# Build CLI
go build -o bin/gormreuse ./cmd/gormreuse
# Run linter on a project
./bin/gormreuse ./...- goroutinectx - Goroutine context propagation linter
- zerologlintctx - Zerolog context propagation linter
- ctxweaver - Code generator for context-aware instrumentation
MIT License