Go linter that reports a database handle used inside a transaction it opened.
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.
| Code | What goes wrong |
|---|---|
r.db.Transaction(func(tx *gorm.DB) error {
r.db.Create(&AuditLog{}) // kept
return errFailed // rolls back tx only
}) |
The write survives a rollback. It was never part of the transaction |
r.db.Transaction(func(tx *gorm.DB) error {
// locks the row
tx.Model(o).Update("status", "paid")
// waits for that lock, on another
// connection, until tx ends
return r.db.Model(o).
Update("note", "paid").Error
}) |
The write can wait forever. It runs on another connection, and needs a lock the transaction holds. With a pool of one connection, it waits for the connection itself |
r.db.Transaction(func(tx *gorm.DB) error {
tx.Create(o)
var n int64
// does not count o
r.db.Model(&Order{}).Count(&n)
return nil
}) |
The read 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.
| Method | Command | Needs |
|---|---|---|
| mise (recommended) | mise use "github:mpyw/txbypass@0.2.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 fromPin 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.2.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.2.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.2.0Turn it on in .golangci.yml. Each key under settings is a rule's name, as the flags take it, or invokes, begins or closes. 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
# One function per item, not comma-separated.
invokes:
- (example.com/db.Manager).InTxThen build and run it:
golangci-lint custom # writes ./custom-gcl
./custom-gcl run ./...| 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.
An invoker opens a transaction and invokes a callback inside it. A begin returns the transaction, which stays open until it is committed or rolled back.
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 connection, rather than a pool, 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.
A function of your project that opens a transaction and runs a callback in it is read as an invoker too. No configuration is needed, and a helper in another package works the same way.
func (s *Store) withTx(
ctx context.Context,
fn func(*sql.Tx) error,
) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if err := fn(tx); err != nil {
return errors.Join(err, tx.Rollback())
}
return tx.Commit()
}
func (s *Store) Place(ctx context.Context) error {
return s.withTx(ctx, func(tx *sql.Tx) error {
// s.db bypasses the transaction opened with
// withTx; use tx instead
_, err := s.db.ExecContext(ctx, audit)
return err
})
}The helper may open the transaction with a begin, as above, or with the callback of an invoker or of another helper.
A helper that returns the transaction it begins is read as a begin:
func (s *Store) begin(
ctx context.Context,
) (*sql.Tx, error) {
return s.db.BeginTx(ctx, nil)
}
tx, err := s.begin(ctx)
if err != nil {
return err
}
defer tx.Rollback()
// s.db bypasses the transaction opened with begin;
// use tx instead
s.db.ExecContext(ctx, audit)Helpers that are not read, so a bypass in a callback given to one is not reported
| Helper | Why it is not read |
|---|---|
func (s *Store) reuse(
ctx context.Context,
cur *sql.Tx,
fn func(*sql.Tx) error,
) error {
if cur != nil {
return fn(cur)
}
tx, err := s.db.BeginTx(ctx, nil)
// ...
} |
It may call the callback outside the transaction it opens. Every use inside would be reported on that path |
func (s *Store) withTx(
ctx context.Context,
fn func(*sql.Tx) error,
) error {
s.hooks = append(s.hooks, fn)
// ...
go fn(tx)
// ...
} |
It stores the callback, passes it on, or runs it with go or defer. The callback may run outside the transaction |
var db *sql.DB
func withTx(
ctx context.Context,
fn func(*sql.Tx) error,
) error {
tx, err := db.BeginTx(ctx, nil)
// ...
} |
It opens on a package-level variable, or on a handle a call returns. Only a parameter, or a field of one, can name the handle at the call |
func (s *Store) withTx(
ctx context.Context,
fn func(context.Context) error,
) error {
tx, err := s.db.BeginTx(ctx, nil)
// ...
return fn(ctxWithTx(ctx, tx))
} |
It carries the transaction in a context. Its body is not read for that yet. Declare it |
func (s *Store) begin(
ctx context.Context,
) (*Tx, error) {
tx, err := s.db.BeginTx(ctx, nil)
return &Tx{tx}, err
} |
It returns the transaction wrapped in a struct, or one it was given. The struct is not followed, and a transaction it was given is not new |
func (s *Store) begin(
ctx context.Context,
) (*sql.Tx, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
return tx, nil
} |
It defers a call given the transaction it returns. The call may end the transaction before the caller uses it |
func (s *Store) begin(
ctx context.Context,
) (tx *sql.Tx, err error) {
defer annotate(&err)
return s.db.BeginTx(ctx, nil)
} |
It gives the address of its error to a call. The call may return an open transaction beside an error |
func (s *Store) withTx(
ctx context.Context,
pre func(*sql.Tx) error,
fn func(*sql.Tx) error,
) error |
It takes two callbacks. Only the first that runs inside the transaction is read, here pre |
s := Store{db: db}
s.withTx(ctx, func(tx *sql.Tx) error {
_, err := s.db.ExecContext(ctx, q)
return err
}) |
It is a method with a pointer receiver, called on a struct held by value. The call takes the address of s, and a method of the module given it may change what s.db holds |
The exact conditions are in design/rules.md and Helpers that are begins.
A helper whose body cannot be read has to be declared. That is usually an interface method, as a transaction manager given by dependency injection.
type Store interface {
//txbypass:invokes
WithTx(ctx context.Context, fn func(q Querier) error) error
//txbypass:begins
Begin(ctx context.Context) (Tx, error)
}
type Tx interface {
//txbypass:closes
Done(err error) error
}| Directive | Declares |
|---|---|
//txbypass:invokes |
A call opens a transaction, and runs the callback inside it |
//txbypass:begins |
A call returns a transaction, open |
//txbypass:closes |
A call ends every transaction it is given, its receiver included |
The handle is the receiver. The callback is the first parameter of a function type. It is given the transaction at its first parameter that may be one. A number, a string, a bool, an error, a context or a function may not. A callback that takes a context and no transaction gets the transaction in the context, and outer-context reads it. The exact rules are in design/rules.md.
For code you cannot edit, pass the names in a flag:
txbypass \
-invokes '(example.com/db.Manager).InTx' \
-begins '(example.com/db.Manager).Begin' \
-closes '(example.com/db.Tx).Done' \
./...Each flag takes a comma-separated list of names of its kind. A flag given twice declares the names of both:
txbypass \
-invokes '(example.com/db.Manager).InTx,(example.com/db.Store).WithTx' \
./...A comma inside the brackets of type arguments does not split, as in (example.com/db.Map[K, V]).InTx. The golangci-lint plugin takes one name per item of a list instead.
| Kind | Spelling |
|---|---|
| A function | example.com/db.InTx |
| A method | (example.com/db.Manager).InTx |
| A method with a pointer receiver | (*example.com/db.Manager).InTx. The * may be left out |
A name not spelled this way stops the run. A name that is spelled right but names nothing is not reported, so check the package path.
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'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 |
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"})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) |
Some invokers 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 invoker, 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 |
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)
},
)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 listWithout the binary, gh skill install mpyw/txbypass txbypass-authoring --agent claude-code writes to the same directories. The installer is go-skill-embed.
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 |
| 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.reuse(ctx, cur, func(tx *sql.Tx) error {
_, err := s.db.ExecContext(ctx, q)
return err
}) |
A helper that may run the callback outside the transaction is not read as one. See Helpers of your own | |
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.
The Go gopher was designed by Renée French, under CC BY 4.0.