I Shipped a Transaction Bug, So I Built a Linter
I Shipped a Transaction Bug, So I Built a Linter
Author: léon h
Date: 27 January 2026
URL: https://leonh.fr/posts/go-transaction-linter/
Overview
Léon h describes building a custom Go linter after shipping a database transaction bug to production. The linter, called transactioncheck, detects when database operations leak outside their intended transaction boundaries—a structural bug that compiles cleanly and passes tests but causes silent data corruption.
The Problem
The bug occurs in codebases using callback-based transactions with repository patterns. When updating code:
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 bypasses the transaction. This mistake is difficult to catch because code compiles, tests pass in isolation, and failures occur unpredictably under load with "silent data corruption" as the failure mode.
Solution: The go/analysis Framework
The linter leverages the standard go/analysis framework. A basic analyzer structure includes:
- Name: transactioncheck
- Dependencies: inspect.Analyzer for optimized AST traversal
- Entry point: runTransactionCheck function receiving analysis.Pass
Implementation Strategy
1. AST Walking
The linter filters for call expressions (*ast.CallExpr) to identify transaction calls without inspecting every node.
2. Transaction Identification
Detects repository interfaces by name and package location, then identifies Transaction method calls on those repositories.
3. Parameter Tracking
Captures the transaction parameter (e.g., tx) using Go's type system. "Two identifiers referring to the same variable share the same types.Object, which we can compare with simple equality check" for reliable tracking across name shadowing.
4. Violation Detection
Two violation types:
Direct method calls on outer repo:
s.repo.GetUser(ctx, userID) // Should use tx.GetUser
Passing outer repo to helper functions:
helperFunction(ctx, s.repo, userID) // Should pass tx
5. Recursive Analysis
The linter follows helper function chains to catch violations nested several functions deep:
s.processOrder(ctx, tx, userID) // Recurses into processOrder
Visited functions are tracked to prevent infinite loops.
Testing with analysistest
The analysistest package simplifies testing. Test cases use // want comments for assertions:
_ = s.repo.GetUser( // want "using non-transaction repo..."
ctx, "123",
)
Deployment
Rather than integrating with golangci-lint, the standalone tool runs as part of build tasks. The team uses mise as a task runner:
[tasks.transactioncheck]
run = 'bin/transactioncheck ./...'
The linter integrates into CI, preventing violations from reaching production.
Results
When first deployed, the linter "found multiple violations across the codebase," demonstrating real bugs rather than hypothetical ones. The two-day project provides ongoing protection with minimal maintenance overhead.
Key Resources
- Repository: https://github.com/leonhfr/transactioncheck
- Framework Documentation: golang.org/x/tools/go/analysis
- Task Runner: mise.jdx.dev