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
}
log := &AuditLog{OrderID: o.ID}
return r.db.Create(log).Error
},
)
}
$ txbypass ./...
order.go:19:11: 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 of a rule ends with the rule's name. That name is what //txbypass:ignore takes. A report about a directive itself names no rule, and no ignore silences it.
Install
| Method |
Command |
Needs |
| mise (recommended) |
mise use "github:mpyw/txbypass@0.1.0" |
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
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.
[tools]
"github:mpyw/txbypass" = "0.1.0"
As a tool dependency in go.mod:
go get -tool github.com/mpyw/txbypass/cmd/txbypass@latest
go tool txbypass ./...
Through go vet, which runs it with the same package loading as the rest of your vet checks, and caches it:
go vet -vettool=$(which txbypass) ./...
Without installing anything:
go run github.com/mpyw/txbypass/cmd/txbypass@latest ./...
From a release archive, verified against the published checksums:
VERSION=0.1.0
REPO=https://github.com/mpyw/txbypass
BASE="${REPO}/releases/download/v${VERSION}"
curl -LO "${BASE}/txbypass_${VERSION}_darwin_arm64.tar.gz"
curl -LO "${BASE}/checksums.txt"
shasum -a 256 -c checksums.txt --ignore-missing
tar xzf "txbypass_${VERSION}_darwin_arm64.tar.gz"
Inside golangci-lint, as a module plugin. txbypass is not bundled with golangci-lint, so build a binary that holds it. Write .custom-gcl.yml:
version: v2.13.1 # the golangci-lint release to build
plugins:
- module: github.com/mpyw/txbypass
import: github.com/mpyw/txbypass/plugin
version: v0.1.0
Turn it on in .golangci.yml. Each key under settings is a rule's name, as the flags take it. A rule left out keeps its default. An unknown name stops the run:
version: "2"
linters:
enable:
- txbypass
settings:
custom:
txbypass:
type: module
description: Transaction bypass linter
settings:
outer-handle: true
Then build and run it:
golangci-lint custom # writes ./custom-gcl
./custom-gcl run ./...
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:
# every rule except outer-context
txbypass -outer-context=false ./...
Nothing is reported in a generated file. Test files are checked like any other file.
Supported libraries
| Library |
Callback |
Begin ... Commit |
| database/sql |
|
DB.Begin, DB.BeginTx |
| sqlx |
|
DB.Beginx, DB.BeginTxx, DB.MustBegin, DB.MustBeginTx |
| pgx v5 |
BeginFunc, BeginTxFunc |
Tx.Begin, pgxpool.Pool.Begin, pgxpool.Pool.BeginTx |
| pgx v4 |
Tx.BeginFunc, pgxpool.Pool.BeginFunc, pgxpool.Pool.BeginTxFunc |
Tx.Begin, pgxpool.Pool.Begin, pgxpool.Pool.BeginTx |
| gorm |
DB.Transaction |
DB.Begin |
| bun |
DB.RunInTx, Tx.RunInTx, IDB.RunInTx |
DB.Begin, DB.BeginTx, Tx.Begin, Tx.BeginTx, IDB.BeginTx |
| bob |
DB.RunInTx |
DB.Begin, DB.BeginTx |
| ent |
|
Client.Tx, Client.BeginTx, generated into your package |
| goqu |
Database.WithTx |
Database.Begin, Database.BeginTx |
| go-pg |
DB.RunInTransaction |
DB.Begin, DB.BeginContext |
| pop |
Connection.Transaction, Connection.Rollback |
Connection.NewTransaction, Connection.NewTransactionContext, Connection.NewTransactionContextOptions |
| upper/db |
Session.Tx, Session.TxContext |
|
| xorm v1, v2 |
Engine.Transaction |
|
| go-redis v9 |
Client.Watch, Client.TxPipelined, UniversalClient.Watch, Cmdable.TxPipelined |
|
| go-redis v8 |
Client.Watch, Client.TxPipelined, UniversalClient.Watch, Cmdable.TxPipelined |
|
| Firestore |
Client.RunTransaction |
|
| Spanner |
Client.ReadWriteTransaction, Client.ReadWriteTransactionWithOptions |
|
| Datastore |
Client.RunInTransaction |
Client.NewTransaction |
| MongoDB v2 |
Session.WithTransaction, WithSession, Client.UseSession, Client.UseSessionWithOptions, read by outer-context |
|
| MongoDB v1 |
Session.WithTransaction, WithSession, Client.UseSession, Client.UseSessionWithOptions, read by outer-context |
|
| go-rel |
Repository.Transaction, read by outer-context |
|
| go-transaction-manager v2 |
trm.Manager.Do, trm.Manager.DoWithSettings, manager.Manager.Do, manager.Manager.DoWithSettings, read by outer-context |
|
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.
[!NOTE]
A transaction begun on one dedicated connection is not read. *sql.Conn, *pgx.Conn, *pgxpool.Conn, bun.Conn, bob.Conn and go-pg's *pg.Conn are such connections. A statement on the connection runs inside the transaction, so it bypasses nothing. A savepoint, a transaction opened on a transaction, is not read either, for the same reason. The Tx of upper/db is no savepoint on a transaction: it begins another transaction on the pool.
outer-handle
Inside a transaction, every use of the handle it was opened on is reported. The transaction can be opened in two ways.
| Opened by | Inside means | Example |
| A callback |
The callback's body. Its goroutines and deferred calls count too. They run while the transaction is open |
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, debit); err != nil {
return err
}
// pool bypasses the transaction opened with
// pgx.BeginFunc; use tx instead
_, err := pool.Exec(ctx, credit)
return err
})
|
| A begin |
Where the transaction is open on every path: after the begin, and before its commit or rollback |
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err // the transaction did not begin here
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, debit); err != nil {
return err
}
// db bypasses the transaction opened with
// BeginTx; use tx instead
if _, err := db.ExecContext(ctx, credit); err != nil {
return err
}
return tx.Commit()
|
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.
| Code | Valid? | Reason |
db.Transaction(func(tx *gorm.DB) error {
return db.Create(u).Error
})
|
❌ |
db opened the transaction |
db.Transaction(func(tx *gorm.DB) error {
return db.WithContext(ctx).Create(u).Error
})
|
❌ |
A method that returns its receiver's type gives the same handle back |
d := db
db.Transaction(func(tx *gorm.DB) error {
return d.Create(u).Error
})
|
❌ |
A copy of the handle is the handle |
db.Transaction(func(tx *gorm.DB) error {
return audit(db)
})
|
❌ |
Passing the handle on is a use, as to jet's stmt.Query(db, &dest) |
r.db.Transaction(func(tx *gorm.DB) error {
return r.db.Save(u).Error
})
|
❌ |
A field selected from the same variable is the same handle |
type Store struct{ *gorm.DB }
s.Transaction(func(tx *gorm.DB) error {
return s.Create(u).Error
})
|
❌ |
A method promoted from an embedded handle reaches it |
type Store struct {
*gorm.DB
logger *log.Logger
}
s.Transaction(func(tx *gorm.DB) error {
s.logger.Print("saving")
return tx.Create(u).Error
})
|
✅ |
A struct that embeds the handle is not the handle. Its other fields and methods are no uses |
var after []func()
err := db.Transaction(func(tx *gorm.DB) error {
after = append(after, func() { db.Create(&Event{}) })
return tx.Create(u).Error
})
|
✅ |
A literal kept in a variable declared outside the callback is an after-commit hook. It runs after the transaction, on purpose |
dbs.Main.Transaction(func(tx *gorm.DB) error {
return dbs.Main.Create(u).Error
})
|
❌ |
A variable of another package is a handle too |
tx.Transaction(func(sp *gorm.DB) error {
return tx.Create(u).Error
})
|
✅ |
A savepoint runs on the connection of its transaction, so tx runs inside it |
//txbypass:ignore outer-handle // must survive a rollback
res := db.Create(&log)
if res.Error != nil {
return res.Error
}
|
✅ |
The statement is reported once. Reading its error or RowsAffected runs nothing |
db.Transaction(func(tx *gorm.DB) error {
t.Cleanup(func() { db.Delete(&u) })
return tx.Create(&u).Error
})
|
✅ |
A function given to t.Cleanup runs after the test, when the transaction has ended |
db.RunInTx(ctx, nil, func(
ctx context.Context,
tx bun.Tx,
) error {
_, err := db.NewInsert().
Model(u).
Conn(tx).
Exec(ctx)
return err
})
|
✅ |
bun's Conn(tx) runs the query built on db on the transaction |
db.Transaction(func(tx *gorm.DB) error {
return logs.Create(u).Error
})
|
✅ |
logs is another handle |
r.db.Transaction(func(tx *gorm.DB) error {
return r.logs.Save(u).Error
})
|
✅ |
r.logs is another field |
db.Transaction(func(db *gorm.DB) error {
return db.Create(u).Error
})
|
✅ |
The parameter shadows the outer db |
| Code | Valid? | Reason |
tx, err := db.Begin()
if err != nil {
db.Exec(logFailure)
return err
}
defer tx.Rollback()
|
✅ |
The transaction did not begin |
tx, _ := db.Begin()
if err := tx.Commit(); err != nil {
return err
}
db.Exec(refresh)
|
✅ |
The transaction has ended |
tx, _ := db.Begin()
if done {
tx.Commit()
}
db.Exec(refresh)
|
✅ |
It may have ended |
tx, _ := db.Begin()
defer db.Exec(audit)
return tx.Commit()
|
✅ |
It runs when the function returns |
tx, _ := db.Begin()
t := tx
t.Commit()
db.Exec(refresh)
|
✅ |
A copy of the transaction is the transaction |
tx, err := db.Begin()
finish(tx, err) // commits or rolls back
db.Exec(refresh)
|
✅ |
A helper that ends the transaction ends it, in this package or another |
tx, _ := db.Begin()
h.tx = tx
db.Exec(refresh)
|
✅ |
The transaction went where this analysis does not follow. Other code may end it |
tx, err := db.Begin()
if err != nil {
log.Fatal(err)
}
if _, err := tx.Exec(debit); err != nil {
tx.Rollback()
log.Fatal(err)
}
db.Exec(audit)
|
❌ |
The path through log.Fatal never comes back, so the transaction is open on every other one |
tx, _ := db.Begin()
go func() {
db.Exec(audit)
}()
return tx.Commit()
|
❌ |
It starts while the transaction is open |
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:
| Code | Valid? | Reason |
tx.Get(client.Doc("users/a"))
|
✅ |
The chain of calls on client takes no context. It only builds a reference |
ref := client.Collection("users").Doc("b")
|
✅ |
The same |
client.Doc("users/c").Set(ctx, v)
|
❌ |
A call in the chain takes a context, so it runs |
save(ctx, client)
|
❌ |
The client on its own is given to a function |
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
}
// q bypasses the transaction opened with
// BeginTx; use qtx instead
if err := q.CreateUser(ctx, b); err != nil {
return err
}
return tx.Commit()
Any method named WithTx qualifies, when it takes the transaction and returns its receiver's type. The transaction may be a field of it, as q.WithTx(tx.Tx) with sqlx or bun. The method may be promoted, as from the *Queries that sqlc's Store embeds. Its receiver must hold nothing but the database, as sqlc's Queries does. The statements sqlc prepares with emit_prepared_queries count as the database. 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 // 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:
| Library | A helper can take |
| database/sql |
// *sql.DB and *sql.Tx both have these.
type DBTX interface {
ExecContext(
ctx context.Context,
query string,
args ...any,
) (sql.Result, error)
QueryContext(
ctx context.Context,
query string,
args ...any,
) (*sql.Rows, error)
QueryRowContext(
ctx context.Context,
query string,
args ...any,
) *sql.Row
}
|
| gorm |
*gorm.DB. The transaction has the handle's type |
| bun |
bun.IDB. *bun.DB, bun.Conn and bun.Tx implement it |
| pgx |
// A pool, a connection and a transaction all have these.
type DBTX interface {
Exec(
ctx context.Context,
sql string,
args ...any,
) (pgconn.CommandTag, error)
Query(
ctx context.Context,
sql string,
args ...any,
) (pgx.Rows, error)
QueryRow(
ctx context.Context,
sql string,
args ...any,
) pgx.Row
}
|
| sqlc |
DBTX, which sqlc generates. Or q.WithTx(tx) |
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
}
// ctx bypasses the transaction opened with
// WithTransaction; use sc instead
return audits.InsertOne(ctx, a)
},
)
| Code | Valid? | Reason |
sess.WithTransaction(
ctx,
func(sc context.Context) (any, error) {
return coll.InsertOne(ctx, doc)
},
)
|
❌ |
ctx was given to the opener, and carries no transaction |
sess.WithTransaction(
ctx,
func(sc context.Context) (any, error) {
return coll.InsertOne(sc, doc)
},
)
|
✅ |
sc carries the transaction |
sess.WithTransaction(
ctx,
func(ctx context.Context) (any, error) {
return coll.InsertOne(ctx, doc)
},
)
|
✅ |
The parameter shadows the outer ctx |
repo.Transaction(ctx, func(txCtx context.Context) error {
return repo.Insert(txCtx, u)
})
|
✅ |
The repository is not the handle here. The context decides the transaction |
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:
# the agents already set up in this project
txbypass skill install
txbypass skill install --agent claude-code --scope user
# where it is, and whether it is current
txbypass skill list
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 // 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 a rule it covers is turned off. A bare ignore covers every rule |
Limits
⚠️ marks code that bypasses the transaction, but is not reported:
| Code | Valid? | Reason |
db.Transaction(r.place)
func (r *Repo) place(tx *gorm.DB) error {
return r.db.Create(u).Error
}
|
⚠️ |
A callback that is not a function literal has no body here to read |
s.withTx(ctx, func(tx *sql.Tx) error {
_, err := s.db.ExecContext(ctx, q)
return err
})
|
⚠️ |
A literal given to a transaction helper of your own is not read. Only a call of a known opener opens a scope. Inside the helper, the callback is a function value |
db.Transaction(func(tx *gorm.DB) error {
return r.audit(ctx) // audit uses r.db
})
|
⚠️ |
Only the code inside the transaction is read for uses, not the functions it calls |
open().Transaction(func(tx *gorm.DB) error {
return open().Create(u).Error
})
|
⚠️ |
Each call gives a value of its own, so a second open() is another handle |
r.tx, err = db.Begin()
db.Exec(audit)
|
⚠️ |
Only a begin kept in a variable opens a region |
client.RunTransaction(
ctx,
func(
ctx context.Context,
tx *firestore.Transaction,
) error {
ref := client.Doc(p)
_, err := ref.Set(ctx, v)
return err
},
)
|
⚠️ |
ref is a Firestore reference, not the client |
tx, _ := db.Begin()
job := Job{tx: tx}
job.Run()
db.Exec(audit)
|
⚠️ |
A transaction placed in a struct ends the region: where it goes from there is not followed |
tx, _ := db.Begin() // db is a *sqlx.DB
db.MustExec(audit)
|
⚠️ |
Begin is promoted from the embedded *sql.DB, and db is a struct that embeds it. Use sqlx's Beginx |
(*gorm.DB).Transaction(db, func(tx *gorm.DB) error {
return db.Create(u).Error
})
|
⚠️ |
A method expression takes its receiver as an argument |
db.Transaction(func(tx *gorm.DB) error {
return audit(db, tx)
})
|
⚠️ |
A call given the transaction too keeps the handle beside it, as a constructor newStore(db, tx) does. What the call does with the handle is not read |
sess := engine.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
}
engine.Insert(&u)
return sess.Commit()
|
⚠️ |
xorm's session is the transaction, and its Begin returns only an error, so no region is read. engine.Transaction(...) is read |
m.Do(c, func(ctx context.Context) error {
return saveOrder(c) // takes *gin.Context
})
|
⚠️ |
A framework context given to a parameter of its own type is taken as that type, not as a context. What the helper does with it is not read |
These are reported, although the transaction may have ended before the use:
| Code | Valid? | Reason |
tx, _ := db.Begin()
end(tx) // end is a func(*sql.Tx) parameter
db.Exec(refresh)
|
❌ |
The body of a function value, or of an interface method, is not known. The transaction is taken to stay open |
tx, _ := db.Begin()
repo.Keep(tx) // stores tx; other code commits it
db.Exec(refresh)
|
❌ |
Inside a helper, only an end it writes counts. Commit in the function, or write an ignore |
The full specification, with every limit, is design/rules.md.
License
MIT
The Go gopher was designed by Renée French, under CC BY 4.0.