Building a Go Transaction Linter
- title
- Building a Go Transaction Linter
- type
- summary
- summary
- How léon h built transactioncheck after shipping a transaction leak bug to production
- tags
- golang, static-analysis, databases
- sources
- go-transaction-linter
- created
- 2026-04-15
- updated
- 2026-04-15
Léon h shipped a bug where a database read bypassed its transaction, causing silent data corruption. The fix compiled, tests passed, but the pattern was easy to introduce again. So he spent two days building a custom linter.
The bug
Callback-based transactions are common in Go codebases with repository patterns:
return s.repo.Transaction(ctx, func(tx models.Repo) error {
user, err := s.repo.GetUser(ctx, userID) // Bug: uses s.repo, not tx
return tx.SaveUser(ctx, user)
})
The GetUser call uses s.repo instead of the tx parameter, so it runs outside the transaction. The read isn't part of the atomic unit. Under load, another process can modify the user between the read and write, and the stale data gets saved.
This bug is insidious: it compiles cleanly, unit tests pass (no concurrency in isolation), and failures are non-deterministic. You only catch it when data goes wrong in production.
The linter
transactioncheck uses the go-analysis-framework to detect these leaks statically. The approach:
- Walk the AST looking for
Transactionmethod calls on repository interfaces - Capture the callback's transaction parameter (
tx) - Inside the callback body, flag any use of the outer repository instead of
tx
Two violation patterns:
- Direct calls:
s.repo.GetUser(...)inside the callback - Indirect passing:
helperFunction(ctx, s.repo, ...)wheretxshould be passed
The clever bit is parameter tracking. Go's type checker gives each variable a unique types.Object. Two identifiers pointing to the same variable share the same object, so equality comparison works reliably even with shadowing.
Recursive analysis
The linter doesn't stop at the callback boundary. If the callback calls a helper function and passes tx, the linter recurses into that function to check for violations there too. It tracks visited functions to avoid infinite loops.
This catches bugs like:
s.repo.Transaction(ctx, func(tx models.Repo) error {
return s.processOrder(ctx, tx, orderID) // processOrder internally uses s.repo
})
Deployment
Rather than wiring into golangci-lint, the team runs it standalone via mise:
[tasks.transactioncheck]
run = 'bin/transactioncheck ./...'
On first run, it found multiple real violations in the codebase. Two days of work for ongoing protection against a class of bugs that would otherwise only surface as production incidents.