I shipped a transaction bug, so I built a linter

A developer shares their journey of building a custom Go linter to catch insidious database transaction leaks that bypass standard tests and code reviews.
Table of Contents
Some bugs compile cleanly, pass all tests, and slip through code reviews. I shipped one of those at work: a database transaction that silently leaked operations outside its boundary. I don’t like being stressed with a broken prod, so I built a custom linter to catch them at compile time. Here’s how.
The Bug: Leaking Transactions
Database transactions are essential for data integrity. Operations wrapped in a transaction are expected to display all-or-nothing behavior: either every operation succeeds, or everything rolls back.
One pattern for managing transactions is callbacks. This style of transaction is common when using ORMs such as Gorm. However, this approach makes it easier to accidentally bypass transaction boundaries. Operations can leak outside the intended scope, leading to data corruption and race conditions. Let’s look at some code.
At my current workplace, the Go backend uses a repository pattern with explicit transactions:
func (s *Service) UpdateUser(ctx context.Context, userID string) error {
return s.repo.Transaction(ctx, func(tx models.Repo) error {
user, err := tx.GetUser(ctx, userID)
if err != nil {
return err
}
user.Name = "Updated"
return tx.SaveUser(ctx, user)
})
}
The callback receives tx, a transaction-scoped repository. All database operations inside must use tx to participate in the transaction.
This pattern works, but it’s easy to mix up the two scopes:
func (s *Service) UpdateUser(ctx context.Context, userID string) error {
return s.repo.Transaction(ctx, func(tx models.Repo) error {
user, err := s.repo.GetUser(ctx, userID) // Bug: uses s.repo, not tx.
if err != nil {
return err
}
user.Name = "Updated"
return tx.SaveUser(ctx, user) // Ok: inside the transaction.
})
}
The GetUser call uses s.repo (the component’s field) instead of tx (the transaction callback parameter). This operation executes outside the transaction boundary.
This mistake is easy to miss because the code often appears to work correctly. It typically happens when wrapping existing code in a transaction and forgetting to update one reference.
It’s Hard to Catch
What’s more, this bug is insidious and hard to catch. The code compiles without error. Tests pass because they run in isolation with no contention. Failures are unpredictable and usually only happen under load. Worst of all, the failure mode is often silent data corruption, not loud and easy-to-diagnose crashes.
It’s also easy to miss in code reviews, especially when the bug is nested a few functions deep. AI review tools only caught it when it was obvious, so they were not reliable enough. After some of these slipped through and resulted in long debugging sessions, I decided to find a more efficient way to catch them.
Static analysis is well-suited here. The bug is structural, not behavioral. It’s all about which variable the code references (s.repo vs tx), not about runtime values. The pattern is detectable from source code alone.
That’s why I decided to write a linter. While I use them every day, I wasn’t exactly sure how they worked nor where to start. However, writing one seemed like an interesting challenge, so I’m sharing what I learned.
The go/analysis Framework
The current standard for Go linters is the go/analysis framework. It makes static analysis surprisingly accessible. The framework lets you focus on the linter logic while it handles all the complexity of parsing, type-checking, and running analyses.
The package provides a standardized structure for building analyzers with the analysis.Analyzer type:
func NewAnalyzer() *analysis.Analyzer {
return &analysis.Analyzer{
Name: "transactioncheck",
Doc: "checks that Transaction callbacks use the transaction-scoped repo parameter",
Run: runTransactionCheck,
Requires: []*analysis.Analyzer{inspect.Analyzer},
}
}
The Run field contains a function that executes the analysis on a single package. It receives an *analysis.Pass struct containing everything needed for analysis: parsed AST, type information, and a Reportf method for flagging violations.
The Requires field specifies a list of other analyzers this one depends on. Here, we depend on inspect.Analyzer, which provides an optimized AST traversal mechanism.
Following Go convention, where linters typically have a check suffix (like staticcheck or nilcheck), I called mine transactioncheck.
Implementation Deep Dive
Walking the AST
Here’s the entry point of the analyzer:
func runTransactionCheck(pass *analysis.Pass) (any, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
nodeFilter := []ast.Node{(*ast.CallExpr)(nil)}
inspect.Preorder(nodeFilter, func(n ast.Node) {
callExpression := n.(*ast.CallExpr)
if !isTransactionCall(pass, callExpression) {
return
}
// Analyze the transaction callback...
})
return nil, nil
}
We filter for call expressions (*ast.CallExpr). We want to inspect every function or method call, and nothing else. This avoids visiting every node in the AST. Then, if it’s a Transaction call, we analyze its callback for violations.
Identifying Transaction Calls
Before we can detect misused repositories, we need to determine whether a call is a transaction call. The implementation of this is specific to each codebase.
First, we need to detect repository interfaces. In our example, we have a unique models.Repo interface. We match it by name (Repo) and package location (example.com/models).
Now that we can identify repositories, we need to detect transaction calls. In the AST, method calls like repo.DoSomething() are represented as selector expressions (*ast.SelectorExpr), while direct calls like doSomething() are not. We check that the node is a selector expression, the method name is Transaction, and the receiver is a repository interface.
Tracking the Transaction Parameter
When we find a transaction call, we extract the first parameter of the callback. This is the transaction-scoped parameter, tx. We need to verify that all operations within the callback use it, which means tracking it while we traverse the callback’s AST.
We capture two things:
- the parameter name (
txParameterName) for error messages like “should use transaction parametertx” - the type object (
txParameterObject) for identity comparison.
In Go’s type system, two identifiers referring to the same variable share the same types.Object, which we can compare with a simple equality check ==. This allows us to track the transaction parameter without relying on name matching, which would fail anyway if the variable name is shadowed.
func isTransactionParameter(
pass *analysis.Pass,
expression ast.Expr,
txParameterObject types.Object,
) bool {
identifier, ok := expression.(*ast.Ident)
if !ok {
return false
}
object := pass.TypesInfo.Uses[identifier]
if object == nil {
object = pass.TypesInfo.Defs[identifier]
}
return object == txParameterObject
}
Traversing the Callback
For each transaction callback we find, we traverse its AST to look for violations. We use ast.Inspect to walk every node:
func checkTransactionCallback(
pass *analysis.Pass,
functionBody *ast.BlockStmt,
) {
ast.Inspect(functionBody, func(n ast.Node) bool {
callExpr, ok := n.(*ast.CallExpr)
if !ok {
return true // The node is not a function call, we keep traversing.
}
if isTransactionCall(pass, callExpr) {
return false // Stop traversing: nested transactions have their own scope.
}
// Check for violations...
return true
})
}
The traversal stops when it encounters a nested transaction. A nested Transaction call creates its own transaction scope with its own tx parameter. Any code inside that nested callback is outside our current analysis scope and will be analyzed separately when we encounter that transaction call.
Source: Hacker News
















