Map

Building a Go Transaction Linter

Wiki summarygolangstatic-analysisdatabases ↳ show in map Markdown
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
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:

  1. Walk the AST looking for Transaction method calls on repository interfaces
  2. Capture the callback's transaction parameter (tx)
  3. 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, ...) where tx should 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.