A static analyzer for Go that catches common concurrency mistakes around sync.Mutex, sync.RWMutex, and sync.WaitGroup — before they reach production.
- Why goconcurrencylint?
- Features
- Installation
- Quick Start
- Checks
- Examples
- How It Works
- Project Layout
- Roadmap
- Contributing
- License
Concurrency bugs in Go are notoriously hard to debug: races, deadlocks, and leaked goroutines often surface only under production load. The standard Go toolchain ships -race for data races, but nothing flags structural misuse of synchronization primitives at compile time.
goconcurrencylint fills that gap with control-flow-sensitive static analysis. It walks the AST of every function, tracks lock/unlock and Add/Done state across if, switch, select, loops, and goroutines, and reports paths where synchronization primitives are used incorrectly — including across files of the same package.
It is built on the standard go/analysis framework, so it drops into any Go tooling pipeline without extra machinery.
Install the binary with go install:
go install github.com/sanbricio/goconcurrencylint/cmd/goconcurrencylint@latestThis places goconcurrencylint in $GOBIN (or $GOPATH/bin). Make sure that directory is on your PATH.
Requirements: Go 1.25 or later.
git clone https://github.com/sanbricio/goconcurrencylint.git
cd goconcurrencylint
go build -o goconcurrencylint ./cmd/goconcurrencylintRun the analyzer against your module:
goconcurrencylint ./...Example diagnostics:
mutex.go:12:2: mutex 'mu' is locked but not unlocked
waitgroup.go:23:3: waitgroup 'wg' has Add without corresponding Done
waitgroup.go:41:2: waitgroup 'wg' Go called after Wait
Because the tool is a standard go/analysis single-checker, it accepts the usual package patterns (./..., ./pkg/..., individual import paths) and standard analyzer flags.
| ID | Primitive | Description |
|---|---|---|
lock-without-unlock |
sync.Mutex, sync.RWMutex |
A Lock() / RLock() call has no matching Unlock() / RUnlock() on some execution path. |
unlock-without-lock |
sync.Mutex, sync.RWMutex |
An Unlock() / RUnlock() call is reached without a prior matching lock (including double-unlocks). |
defer-unlock-without-lock |
sync.Mutex, sync.RWMutex |
defer mu.Unlock() / defer mu.RUnlock() is scheduled before the corresponding lock is acquired. |
add-without-done |
sync.WaitGroup |
wg.Add(n) has no matching number of Done() calls on all paths — the counter may never reach zero. |
done-without-add |
sync.WaitGroup |
wg.Done() is called more times than wg.Add() allows, which panics at runtime. |
add-after-wait |
sync.WaitGroup |
wg.Add() is called after wg.Wait() has returned with an empty counter — a classic reuse bug. |
go-after-wait |
sync.WaitGroup |
wg.Go() is called after wg.Wait() returned empty — same family as add-after-wait, specific to Go 1.25's Go method. |
package-level-primitive |
all | Any of the above, applied to package-scoped primitives declared in a different file of the same package. |
All checks are enabled by default and emitted as standard go/analysis diagnostics.
import "sync"
func GoodMutex() {
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
// critical section
}
func GoodWaitGroupGo() {
var wg sync.WaitGroup
wg.Go(func() {
// work
})
wg.Wait()
}import "sync"
// Lock without a matching Unlock.
func BadLockWithoutUnlock() {
var mu sync.Mutex
mu.Lock() // want "mutex 'mu' is locked but not unlocked"
}
// Defer scheduled before the lock is acquired.
func BadDeferUnlockBeforeLock() {
var mu sync.Mutex
defer mu.Unlock() // want "mutex 'mu' has defer unlock but no corresponding lock"
mu.Lock()
}
// Add without a matching Done — wg.Wait() will block forever.
func BadAddWithoutDone() {
var wg sync.WaitGroup
wg.Add(1) // want "waitgroup 'wg' has Add without corresponding Done"
wg.Wait()
}
// Reusing a WaitGroup after Wait returned empty.
func BadWaitGroupGoAfterWait() {
var wg sync.WaitGroup
wg.Wait()
wg.Go(func() {}) // want "waitgroup 'wg' Go called after Wait"
}
// Extra Done — panics at runtime.
func BadExtraDone() {
var wg sync.WaitGroup
wg.Add(1)
wg.Done()
wg.Done() // want "waitgroup 'wg' has Done without corresponding Add"
wg.Wait()
}More representative cases live under pkg/analyzer/testdata/src.
goconcurrencylint is a thin orchestrator over two focused analyzers:
- Mutex analyzer (
pkg/analyzer/mutex) — trackslock,rlock,borrowed lock, anddefer unlockcounters per function, visiting each control-flow node and reconciling state at join points. Final state is validated at function exit. - WaitGroup analyzer (
pkg/analyzer/waitgroup) — collects everyAdd,Done,Wait, andGocall with its position, builds a reachability map for calls inside goroutines, and validates the balance along every path. Calls that escape the function scope are intentionally excluded to minimize false positives.
Both analyzers share helpers for type detection (IsMutex, IsRWMutex, IsWaitGroup), comment-aware filtering, and consistent error reporting.
goconcurrencylint/
├── cmd/goconcurrencylint/ # CLI entry point (singlechecker)
├── pkg/analyzer/
│ ├── analyzer.go # Top-level orchestrator
│ ├── mutex/ # Mutex / RWMutex analyzer
│ ├── waitgroup/ # WaitGroup analyzer
│ ├── common/ # Shared type detection and reporting
│ └── testdata/src/ # analysistest fixtures
├── assets/ # Logo and branding
└── .github/workflows/ # CI and release pipelines
Contributions are welcome. The most useful ones in this phase of the project are:
- Reduced false-positive / false-negative cases — extra
testdatafixtures are the fastest way to harden the analyzer. - Comparisons against overlapping analyzers — if another linter already covers part of this ground, we want to know.
- New checks — proposals for additional concurrency primitives are encouraged; open an issue first to discuss scope.
To get started:
git clone https://github.com/sanbricio/goconcurrencylint.git
cd goconcurrencylint
go test -race ./...Tests use analysistest with // want "…" markers on fixture files under pkg/analyzer/testdata/src.
goconcurrencylint is released under the MIT License.
Built by Santiago Bricio · sanbriciorojas11@gmail.com
