A static analyzer for Go that catches common concurrency mistakes around sync.Mutex, sync.RWMutex, sync.WaitGroup, and sync.Once — 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/goconcurrencylintgoconcurrencylint is also a golangci-lint module
plugin, so you can run it
from the golangci-lint you already have instead of installing a second binary.
Declare it in .custom-gcl.yml:
version: v2.12.2
name: custom-gcl
destination: ./bin
plugins:
- module: github.com/sanbricio/goconcurrencylint
import: github.com/sanbricio/goconcurrencylint/pkg/golangci
version: v0.5.0Run golangci-lint custom to build ./bin/custom-gcl — a golangci-lint binary
with this linter compiled in — then enable it in .golangci.yml:
linters:
enable:
- goconcurrencylint
settings:
custom:
goconcurrencylint:
type: module
description: Detects misuse of sync primitives and channels.
settings:
checks:
- all
- -GCL5001checks is the -checks flag written as a YAML list, one
entry per element, the way golangci-lint writes every other list setting:
-checks "all,-GCL5001" on the command line is checks: [all, -GCL5001] here.
Omit the key to run every check.
Getting it wrong fails the run with exit code 3 rather than quietly dropping a check — a mistyped key, a check that does not exist, an empty list, and the flag's comma-separated string in place of a list are all errors, the last one printing the list to write instead.
Full walkthrough — CI, severity rules, run.tests, and building against a local
checkout: docs/golangci-lint.md.
Run the analyzer against your module:
goconcurrencylint ./...Example diagnostics:
mutex.go:12:2: GCL1001: mutex 'mu' is locked but not unlocked
waitgroup.go:23:3: GCL2001: waitgroup 'wg' has Add without corresponding Done
waitgroup.go:41:2: GCL2004: waitgroup 'wg' Go called after Wait
Every diagnostic is prefixed with a stable code (GCL1001). Run goconcurrencylint explain GCL1001 for a full description of any check, or browse the check catalogue.
Because the tool is a standard go/analysis single-checker, it accepts the usual package patterns (./..., ./pkg/..., individual import paths) and standard analyzer flags.
Each check has a stable code (e.g. GCL1001) shown in the diagnostic message and carried as the analysis.Diagnostic.Category, so golangci-lint and IDE integrations can filter or label by check. The legacy kebab-case slug is still accepted in ignore directives. Per-check pages live under docs/checks/, or run goconcurrencylint explain <code>.
| Code | Slug | Primitive | Description |
|---|---|---|---|
GCL1001 |
lock-without-unlock |
sync.Mutex, sync.RWMutex |
A Lock()/RLock() call has no matching Unlock()/RUnlock() on some execution path. |
GCL1002 |
unlock-without-lock |
sync.Mutex, sync.RWMutex |
An Unlock()/RUnlock() call is reached without a prior matching lock (including double-unlocks). |
GCL1003 |
defer-unlock-without-lock |
sync.Mutex, sync.RWMutex |
A deferred Unlock()/RUnlock() can run while the mutex is unlocked. |
GCL1004 |
unchecked-trylock |
sync.Mutex, sync.RWMutex |
TryLock()/TryRLock() is called without checking the returned boolean. |
GCL1005 |
defer-lock |
sync.Mutex, sync.RWMutex |
defer mu.Lock()/RLock() is used where an unlock was almost certainly intended. |
GCL1006 |
mutex-in-loop |
sync.Mutex, sync.RWMutex |
A mutex is declared inside a loop body, creating a fresh lock per iteration. |
GCL1007 |
defer-unlock-in-loop |
sync.Mutex, sync.RWMutex |
defer mu.Unlock() lives inside a loop body, so the unlock only runs at function return. |
GCL1008 |
rwmutex-api-mismatch |
sync.RWMutex |
Unlock() is used for a read lock, or RUnlock() is used for a write lock. |
GCL1009 |
goroutine-lock-deadlock |
sync.Mutex, sync.RWMutex |
A goroutine started while a lock is held tries to take the same lock before the parent releases it. |
GCL1010 |
panic-before-unlock |
sync.Mutex, sync.RWMutex |
A statically-known out-of-range index can panic between Lock() and a non-deferred unlock. |
GCL1011 |
double-lock |
sync.Mutex, sync.RWMutex |
A second Lock() is taken while the first is still held. |
GCL1012 |
lock-order-cycle |
sync.Mutex, sync.RWMutex |
Two functions acquire the same pair of mutexes in opposite orders — a classic deadlock pattern. |
GCL1013 |
rwmutex-recursive-lock |
sync.RWMutex |
A goroutine re-acquires an RWMutex it already holds in a conflicting mode (read then write, or write then read), which self-deadlocks. |
GCL2001 |
add-without-done |
sync.WaitGroup |
wg.Add(n) has fewer guaranteed Done()s than its count, so the counter can never reach zero. |
GCL2002 |
done-without-add |
sync.WaitGroup |
wg.Done() is called more times than wg.Add() allows, which panics at runtime. |
GCL2003 |
add-after-wait |
sync.WaitGroup |
wg.Add() is called after wg.Wait() returned with an empty counter — a classic reuse bug. |
GCL2004 |
go-after-wait |
sync.WaitGroup |
wg.Go() is called after wg.Wait() returned empty — the Go 1.25 variant of add-after-wait. |
GCL2005 |
add-inside-goroutine |
sync.WaitGroup |
wg.Add() is called from inside a worker goroutine, racing with Wait(). |
GCL2006 |
done-not-deferred |
sync.WaitGroup |
A worker calls Done() on a path a runtime.Goexit or recovered panic can skip, instead of deferring it. |
GCL2007 |
add-loop-count-mismatch |
sync.WaitGroup |
A literal Add(n) count does not match a statically countable loop of worker goroutines. |
GCL2008 |
add-zero |
sync.WaitGroup |
wg.Add(0) is a no-op and usually means the intended count was lost. |
GCL2009 |
add-negative |
sync.WaitGroup |
wg.Add(n) is called with a negative literal, which panics at runtime. |
GCL2010 |
wait-without-add |
sync.WaitGroup |
A local WaitGroup is waited on without any Add() in the same lifecycle. |
GCL2011 |
wait-deadlock |
sync.WaitGroup |
Wait() is reached while the same goroutine still owes a Done(). |
GCL2012 |
multiple-done-worker |
sync.WaitGroup |
The same worker branch can call Done() more than once. |
GCL2013 |
nested-waitgroup-deadlock |
sync.WaitGroup |
A worker for one WaitGroup waits on another whose release is blocked behind the outer Wait(). |
GCL2014 |
done-outside-goroutine |
sync.WaitGroup |
Done() runs on the parent goroutine instead of the worker, so a panic in the parent skips it. |
GCL2015 |
go-panic |
sync.WaitGroup |
A function passed to wg.Go() may panic and bring the program down. |
GCL3001 |
once-do-deadlock |
sync.Once |
once.Do(f) where f calls Do on the same Once again — Once.Do is not reentrant, so this deadlocks. |
GCL3002 |
once-do-nil |
sync.Once |
once.Do(nil) panics when the function is invoked. |
GCL3003 |
once-constructor-nil |
sync.Once |
sync.OnceFunc/OnceValue/OnceValues is called with a nil function, which panics when the memoized function first runs. |
GCL4001 |
cond-new-nil-locker |
sync.Cond |
sync.NewCond(nil) builds a Cond whose Locker is nil, so the first Wait panics at runtime. |
GCL5001 |
pool-non-pointer-value |
sync.Pool |
A non-pointer value is placed in a sync.Pool (a Put argument or a New return), so every call boxes it into an interface and heap-allocates — defeating the pool. |
GCL6001 |
close-of-nil-channel |
channel |
close() is called on a channel that is nil on every path reaching the call, which panics at runtime. |
GCL6002 |
close-of-closed-channel |
channel |
close() is called on a channel that is already closed on every path reaching the call, which panics at runtime. |
GCL6003 |
send-on-closed-channel |
channel |
A value is sent on a channel that is already closed on every path reaching the send, which panics at runtime. |
GCL6004 |
nil-channel-op |
channel |
A send or receive is performed on a channel that is nil on every path reaching the operation, which blocks the goroutine forever. |
GCL9001 |
sync-primitive-copy |
sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Once, sync.Cond, sync.Pool, sync.Map |
A sync primitive (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 code for that case; the diagnostic carries the same category as the in-function variant.
Every check runs by default. -checks narrows that down. It is declared on the
analyzer itself, so it behaves identically in the CLI and in any go/analysis
driver that consumes it.
goconcurrencylint -checks "all,-GCL5001" ./...goconcurrencylint -checks "all,-GCL5*" ./...goconcurrencylint -checks "GCL1*,-GCL1005" ./...The list is processed left to right starting from an empty set: a plain entry
adds the checks it matches, an entry prefixed with - removes them, and all
matches the whole catalogue. Later entries override earlier ones, so
GCL1*,-GCL1005 is "the mutex family except that one" and all,-GCL2*,GCL2001
puts a single check back after excluding its family. This is the same syntax
staticcheck uses for its own
-checks flag.
- Entries may be canonical codes (
GCL1001), legacy slugs (lock-without-unlock), or a code prefix ending in*(GCL1*). Separate several with commas, spaces or semicolons, and quote the value so the shell does not expand the star. - An unknown entry is a flag error, not a silent no-op — a typo in a CI config
fails loudly instead of leaving a check enabled that you believe is off. So is
a list that resolves to no checks at all (
-checks "",-checks "all,-all"): an unset variable in a pipeline should not quietly turn the linter off.
To leave test files out, use the flag the go/analysis driver already provides
— it skips loading them entirely, so the run also gets faster:
goconcurrencylint -test=false ./...Under golangci-lint the equivalent is run.tests: false.
Use these for repo-wide policy and the inline directive below for one-off exceptions.
Place // goconcurrencylint:ignore on the same line as the offending call. Each id may be a canonical code (GCL1001) or the legacy slug (lock-without-unlock); the two forms are interchangeable and can be mixed:
wg.Wait() // goconcurrencylint:ignore GCL2010
mu.Lock() // goconcurrencylint:ignore GCL1001, defer-lock
mu.Lock() // goconcurrencylint:ignore legacy code, see issue #42- A list of one or more check 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 check are treated as a human-readable note, so
// goconcurrencylint:ignore GCL1001 because fooonly silencesGCL1001.
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; an early return can run it while the
// mutex is still unlocked. (An adjacent `defer mu.Unlock(); mu.Lock()` is safe.)
func BadDeferUnlockBeforeLock(cond bool) {
var mu sync.Mutex
defer mu.Unlock() // want "mutex 'mu' has defer unlock but no corresponding lock"
if cond {
return
}
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 an umbrella go/analysis analyzer composed of four
independent sub-analyzers, wired together through the standard Requires graph:
- Mutex analyzer — tracks
lock,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 — collects every
Add,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. - Once analyzer — resolves the function passed to
once.Do(literal, named function, or method value) and reports re-entrantDocalls that deadlock, plusDo(nil)calls that panic. - Copy analyzer — flags any
sync.Mutex,sync.RWMutex,sync.WaitGrouporsync.Once(or a struct embedding one) copied by value.
Two foundation analyzers run once per package and share their results with the sub-analyzers: one discovers sync primitive declarations, the other identifies generated files and builds the comment filters behind // goconcurrencylint:ignore. All checks also share helpers for type detection (IsMutex, IsRWMutex, IsWaitGroup, IsOnce) and deterministic, deduplicated error reporting.
For a contributor-level map of the analyzer graph and the journey of a single diagnostic, see ARCHITECTURE.md.
goconcurrencylint/
├── cmd/goconcurrencylint/ # CLI entry point (singlechecker)
├── pkg/analyzer/
│ ├── analyzer.go # Umbrella analyzer (re-emits sub-analyzer diagnostics)
│ ├── internal/
│ │ ├── driver/ # Shared per-function run skeleton
│ │ ├── primitives/ # Discovers sync primitive names
│ │ ├── filesetup/ # Generated-file detection + comment filters
│ │ ├── mutex/ # Mutex / RWMutex analyzer
│ │ ├── waitgroup/ # WaitGroup analyzer
│ │ ├── copycheck/ # Copy-by-value analyzer
│ │ └── common/ # Shared helpers, check catalogue, reporting
│ └── testdata/src/ # analysistest fixtures
├── pkg/golangci/ # golangci-lint module plugin
├── docs/checks/ # Per-check reference pages + index (generated)
├── scripts/gendocs/ # Check-docs generator (go generate ./...)
├── assets/ # Logo and branding
├── ARCHITECTURE.md # Internal design & data flow
└── .github/workflows/ # CI, release asset build, and integration 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
