Go linter that reports a database handle used from inside a transaction it bypasses
Overview
Go linter that reports a database handle used inside a transaction it opened. The callback receives the transaction as tx. r.db is the pool the transaction came from, so the audit log is written outside the transaction. The code compiles. A test where nothing fails passes too. txbypass finds it from the source. Every report ends with the name of its rule. That name is what //txbypass:ignore takes. Pin a version, run through go vet or golangci-lint, or install from an archive mise use pins the version in the project's mise.toml, so every checkout and CI run the same one. Add -g to install it for every project on your machine instead. Through go vet, which runs it with the same package loading as the rest of your vet checks, and caches it: From a release archive, verified against the published checksums:
README
Go linter that reports a database handle used inside a transaction it opened.
Overview
package app
import (
"context"
"gorm.io/gorm"
)
func (r *OrderRepo) Place(ctx context.Context, o *Order) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(o).Error; err != nil {
return err
}
return r.db.Create(&AuditLog{OrderID: o.ID}).Error
})
}
$ txbypass ./...
order.go:14:10: r.db bypasses the transaction opened with Transaction; use tx instead [outer-handle]
The callback receives the transaction as tx. r.db is the pool the transaction came from, so the audit log is written outside the transaction.
| What goes wrong | Why |
|---|---|
| The write survives a rollback | It was never part of the transaction |
| The write can wait forever | It runs on another connection, and may need a lock the transaction holds |
| The write cannot see the transaction’s rows | They are not committed yet |
The code compiles. A test where nothing fails passes too. txbypass finds it from the source.
Every report ends with the name of its rule. That name is what //txbypass:ignore takes.
Install
| Method | Command | Needs |
|---|---|---|
| mise (recommended) | mise use "github:mpyw/[email protected]" |
Nothing. Installs the prebuilt binary |
go tool |
go get -tool github.com/mpyw/txbypass/cmd/txbypass@latest |
Go 1.27+ |
go install |
go install github.com/mpyw/txbypass/cmd/txbypass@latest |
Go 1.27+ |
| Release archive | See below | Nothing |
txbypass ./...
txbypass -V=full # the release this binary was built from
Rules
| Rule | Default | Reports |
|---|---|---|
outer-handle |
🟢 On | The handle a transaction was opened on, used while the transaction is open→ Use the transaction |
outer-context |
🟢 On | The outer context, used inside a callback whose context carries the transaction→ Use the callback’s context |
Each rule has a flag of its name:
txbypass -outer-context=false ./... # every rule except outer-context
Nothing is reported in a generated file. Test files are checked like any other file.
Supported libraries
Query builders that take the handle as an argument, such as jet and squirrel, need nothing of their own. Passing the outer handle to them is a use. sqlc is covered through WithTx.
The exact receivers and signatures are in design/rules.md.
outer-handle
Inside a transaction, every use of the handle it was opened on is reported. The transaction can be opened in two ways.
A handle is a value and the fields selected from it. Two expressions are the same handle when they hold the same value. The SSA form of the function says so. A copy of the handle is the handle.
Firestore references
Firestore’s client builds the references a transaction reads and writes. Building one runs nothing, so only a use that runs is reported:
sqlc’s WithTx
q.WithTx(tx) rebinds sqlc’s generated Queries to the transaction. Code that calls it means to run on the transaction, so every other use of q there is reported:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
qtx := q.WithTx(tx)
if err := qtx.CreateUser(ctx, a); err != nil {
return err
}
if err := q.CreateUser(ctx, b); err != nil { // q bypasses the transaction opened with BeginTx; use qtx instead
return err
}
return tx.Commit()
Any method named WithTx qualifies, when it takes the transaction and returns its receiver’s type. Its receiver must hold nothing but the database, as sqlc’s Queries does. A repository that holds more, as metrics or a logger, is not rebound: its other members run no statement.
[!TIP] Some writes must survive a rollback, such as an audit log of a failed attempt. Write an ignore with the reason:
//txbypass:ignore outer-handle // the audit log must survive a rollback db.Create(&AuditLog{Action: "failed"})
Fix
Use the transaction:
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(o).Error; err != nil {
return err
}
return tx.Create(&AuditLog{OrderID: o.ID}).Error
})
A helper that takes the handle takes the transaction instead:
outer-context
Some openers carry the transaction in the callback’s context, not in a value. A statement run with the outer context runs outside the transaction:
_, err := sess.WithTransaction(ctx, func(sc context.Context) (any, error) {
if _, err := orders.InsertOne(sc, o); err != nil {
return nil, err
}
return audits.InsertOne(ctx, a) // ctx bypasses the transaction opened with WithTransaction; use sc instead
})
Fix
Use the callback’s context. Name it as the outer one, so the outer one cannot be reached:
_, err := sess.WithTransaction(ctx, func(ctx context.Context) (any, error) {
if _, err := orders.InsertOne(ctx, o); err != nil {
return nil, err
}
return audits.InsertOne(ctx, a)
})
Agent skill
skills/txbypass-authoring is a skill for an AI agent writing code under txbypass. It says how to fix each report, which fixes only hide a problem, and when an ignore is right. The binary carries it:
txbypass skill install # the agents already set up in this project
txbypass skill install --agent claude-code --scope user
txbypass skill list # where it is, and whether it is current
Without the binary, gh skill install mpyw/txbypass txbypass-authoring --agent claude-code writes to the same directories. The installer is go-skill-embed.
Ignoring a report
Write //txbypass:ignore with the rules to silence and a reason after //.
//txbypass:ignore outer-handle // the audit log must survive a rollback
db.Create(&AuditLog{Action: "failed"})
| Placement | Silences |
|---|---|
| On a line of its own | The line below |
| After code | Its own line |
| Directive | Result |
|---|---|
//txbypass:ignore outer-handle // reason |
Silences outer-handle |
//txbypass:ignore // reason |
Silences every rule |
| No reason | Reported, and silences nothing |
| An unknown rule name | Reported |
| An ignore that silences nothing | Reported as unused, unless every rule it names is turned off |
Limits
⚠️ marks code that bypasses the transaction, but is not reported:
These are reported, although the transaction may have ended before the use:
The full specification, with every limit, is design/rules.md.
License
The Go gopher was designed by Renée French, under CC BY 4.0.
Recommended Tools
Try a different keyword or remove a filter.
Install
npx skillfish add mpyw/txbypass