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. |
unchecked-trylock |
sync.Mutex, sync.RWMutex |
TryLock() / TryRLock() is called without checking the returned boolean. |
defer-lock |
sync.Mutex, sync.RWMutex |
defer mu.Lock() / defer mu.RLock() is used where an unlock was almost certainly intended. |
mutex-in-loop |
sync.Mutex, sync.RWMutex |
A mutex is declared inside a loop body, creating a fresh lock per iteration. |
rwmutex-api-mismatch |
sync.RWMutex |
Unlock() is used for a read lock, or RUnlock() is used for a write lock. |
goroutine-lock-deadlock |
sync.Mutex, sync.RWMutex |
A goroutine is started while a lock is held and tries to take the same lock before the parent can release it. |
panic-before-unlock |
sync.Mutex, sync.RWMutex |
An index with a statically known out-of-range value and collection length can panic between Lock() and a non-deferred unlock. |
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. |
add-inside-goroutine |
sync.WaitGroup |
wg.Add() is called from inside a worker goroutine, racing with Wait(). |
done-not-deferred |
sync.WaitGroup |
A worker calls Done() after an explicit panic or runtime.Goexit path instead of deferring it. |
add-loop-count-mismatch |
sync.WaitGroup |
A literal Add(n) count does not match a statically countable loop of worker goroutines. |
add-zero |
sync.WaitGroup |
wg.Add(0) is a no-op and usually means the intended count was lost. |
wait-without-add |
sync.WaitGroup |
A local WaitGroup is waited on without any Add() in the same lifecycle. |
multiple-done-worker |
sync.WaitGroup |
The same worker branch can call Done() more than once. |
nested-waitgroup-deadlock |
sync.WaitGroup |
A worker for one WaitGroup waits on another whose release is blocked behind the outer Wait(). |
done-outside-goroutine |
sync.WaitGroup |
Done() runs on the parent goroutine instead of the worker, so a panic in the parent skips it. |
wait-deadlock |
sync.WaitGroup |
Wait() is reached while the same goroutine still owes a Done(). |
add-negative |
sync.WaitGroup |
wg.Add(n) is called with a negative literal — panics at runtime. |
go-panic |
sync.WaitGroup |
A function passed to wg.Go() may panic and bring the program down. |
defer-unlock-in-loop |
sync.Mutex, sync.RWMutex |
defer mu.Unlock() lives inside a loop body, so the unlock only runs at function return. |
double-lock |
sync.Mutex, sync.RWMutex |
A second Lock() is taken while the first is still held (or a write Lock() while a read lock is held). |
cross-goroutine-unlock |
sync.Mutex, sync.RWMutex |
Unlock() / RUnlock() runs in a different goroutine than the matching lock. |
lock-order-cycle |
sync.Mutex, sync.RWMutex |
Two functions acquire the same pair of mutexes in opposite orders — classic deadlock pattern. |
sync-primitive-copy |
all | A sync.Mutex, sync.RWMutex or sync.WaitGroup (or a struct embedding one) is copied by value. |
All checks above also fire on package-scoped primitives declared in any file of the same package — there is no separate ID for that case; the diagnostic carries the same category as the in-function variant.
Each diagnostic is emitted with a stable analysis.Diagnostic.Category equal to the ID in the table above, so golangci-lint and IDE integrations can filter or label by check.
Place // goconcurrencylint:ignore on the same line as the offending call:
wg.Wait() // goconcurrencylint:ignore wait-without-add
mu.Lock() // goconcurrencylint:ignore lock-without-unlock, defer-lock
mu.Lock() // goconcurrencylint:ignore legacy code, see issue #42- A list of one or more category IDs (separated by spaces, commas or semicolons) silences only those checks on the line.
- A bare
// goconcurrencylint:ignore, or a directive followed only by free text, silences every check on the line. - Tokens after the first one that does not match a known category are treated as a human-readable note, so
// goconcurrencylint:ignore lock-without-unlock because fooonly silenceslock-without-unlock.
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
