Detect Goroutine Leaks in Go Tests With the goleak Package
Detect goroutine leaks in Go tests with go.uber.org/goleak, a hand-written check and testing/synctest, with setup, options, the t.Parallel caveat and CI tips.

A goroutine leak found in production is expensive, because by then it is a memory graph and a post-mortem. The same leak found by a test costs a failed build and a stack trace. Go's test framework does not check for leaks on its own, so the check has to be added. There are three practical ways, from a few lines of your own to a dedicated library, and this article shows all three and how to choose.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64); goleak described from its documentation and not executed here
It follows goroutine leaks, which explains what leaks are and how to find them in a service, and counting goroutines at runtime, and it belongs to the concurrency material. The standard library examples ran on Go 1.25.6. For goleak I rely on its README and package documentation and did not run it for this page.
Option 1: a check you write yourself
The idea is the one used in the earlier demonstrations: count goroutines before the code under test, run it, count again. The wrinkle is timing. A goroutine that has been told to stop takes a moment to exit, so a single comparison right after the call flags goroutines that were about to finish. Retry for a short period before giving up:
package main
import (
"runtime"
"testing"
"time"
)
// noLeaks fails the test if more goroutines exist at the end than at the start.
func noLeaks(t *testing.T) {
t.Helper()
before := runtime.NumGoroutine()
t.Cleanup(func() {
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before {
return
}
time.Sleep(10 * time.Millisecond)
}
buf := make([]byte, 1<<16)
n := runtime.Stack(buf, true)
t.Errorf("goroutine leak: %d before, %d after\n%s", before, runtime.NumGoroutine(), buf[:n])
})
}
func startWorker(stop <-chan struct{}) {
go func() { <-stop }()
}
func TestWorkerStops(t *testing.T) {
noLeaks(t)
stop := make(chan struct{})
startWorker(stop)
close(stop) // the worker exits; the check passes
}=== RUN TestWorkerStops
--- PASS: TestWorkerStops (0.01s)
PASS
ok example.test/lab 1.290snoLeaks records the count, registers a cleanup that waits up to a second for the count to fall back, and on failure prints every goroutine's stack so you can see what is stuck. This catches the plain cases and has two weaknesses. It counts goroutines, so a leak that is offset by an unrelated goroutine exiting goes unseen, and background goroutines started by the test framework or libraries can raise the baseline unpredictably. It is also not safe to use in tests running in parallel, because other tests change the count.
Option 2: testing/synctest
Go 1.25 added testing/synctest, intended for testing time-dependent code with a fake clock. It has a property that makes it a leak detector: a test function runs in a "bubble" with its own goroutines, and when the function returns, the bubble must have no goroutines blocked forever. If any remain, synctest.Test panics. A test that starts a goroutine and leaves it blocked fails by itself:
package main
import (
"testing"
"testing/synctest"
)
func TestNoLeak(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan int, 1)
go func() { ch <- 1 }() // the buffer lets the send finish
<-ch
})
}=== RUN TestNoLeak
--- PASS: TestNoLeak (0.00s)
PASS
ok example.test/lab 1.250sThat test passes. The version with a leak, a goroutine sending on an unbuffered channel that nobody reads, fails with a panic. Run it to see:
package main
func leaky() {
ch := make(chan int)
go func() { ch <- 1 }() // nobody receives
}package main
import (
"testing"
"testing/synctest"
)
func TestLeakyIsCaught(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
leaky()
})
}=== RUN TestLeakyIsCaught
--- FAIL: TestLeakyIsCaught (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]
goroutine 22 [running]:
testing.tRunner.func1.2({0x102da2d20, 0xc00000e018})
testing/testing.go:1872 +0x2b4
testing.tRunner.func1()
testing/testing.go:1875 +0x460
panic({0x102da2d20?, 0xc00000e018?})
runtime/panic.go:783 +0x120
internal/synctest.Run(0xc0000921e0)
runtime/synctest.go:251 +0x2d4
testing/synctest.Test(0xc000082e00, 0x102dbbc10)
testing/synctest/synctest.go:282 +0x114
example.test/lab.TestLeakyIsCaught(0xc000082e00)
example.test/lab/leak_test.go:9 +0x34
testing.tRunner(0xc000082e00, 0x102dbbb80)
testing/testing.go:1934 +0x168
created by testing.(*T).Run in goroutine 1
testing/testing.go:1997 +0x6e4
goroutine 25 [chan send (durable), synctest bubble 1]:
example.test/lab.leaky.func1()
example.test/lab/leak.go:5 +0x3c
created by example.test/lab.leaky in goroutine 24
example.test/lab/leak.go:5 +0x94
FAIL example.test/lab 0.237s
FAILThe output above shows the test failing with panic: deadlock: main bubble goroutine has exited but blocked goroutines remain, followed by the stack of the leaked goroutine, labelled chan send (durable), synctest bubble 1, pointing at the exact line of the blocked send in leak.go. That is the leak report: the function under test returned and a goroutine was still blocked, and you are told which one. It is exact, because the bubble contains only the goroutines the test started, so unrelated goroutines cannot trigger it, and it needs no retry or timeout. The limits come from what a bubble is: the code under test must use only bubble-compatible blocking (channels, mutexes, timers created inside it); real network or file I/O does not block in the bubble's sense, so tests that do real I/O fall outside what it can check. For channel, timer and sync-based code, it is the best option because it is built in (testing with synctest).
Option 3: goleak
go.uber.org/goleak is the established library for the job. Its README gives two entry points. To check a single test, defer VerifyNone:
func TestA(t *testing.T) {
defer goleak.VerifyNone(t)
// test logic here.
}And to check once for the whole package, after all its tests, call VerifyTestMain from TestMain:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}At the end, goleak looks at the running goroutines, filters out the ones that belong to the runtime and the testing framework, retries for a short time to let finishing goroutines exit, and fails with the stacks of any that remain. Its API (from the package index) includes Find, VerifyNone, VerifyTestMain and options: IgnoreCurrent(), which ignores the goroutines that exist when the option is created; IgnoreTopFunction(f), which ignores goroutines whose top function is f; IgnoreAnyFunction(f); and Cleanup. The README notes that goleak supports the two most recent minor versions of Go.
Ignoring goroutines you cannot stop
Some libraries start background goroutines that live for the process: a metrics reporter, a logger flusher, an HTTP client's idle connection reaper. They are not leaks in your code, but they will fail the check. Use the options and name the exact function:
defer goleak.VerifyNone(t,
goleak.IgnoreTopFunction("github.com/some/lib.(*Reporter).loop"),
)Add each ignore with a comment explaining why, and keep the list short. An ignore for a function in your own code is almost always hiding a real leak; fix the leak. A library whose goroutines you cannot stop should offer a Close, and calling it in the test's cleanup is better than ignoring.
The t.Parallel caveat
The README warns that for tests using t.Parallel, goleak cannot tell a leaked goroutine from one belonging to a test still running, so a per-test VerifyNone on parallel tests gives false alarms. The fix is to use VerifyTestMain for packages with parallel tests, which checks once after everything has finished.
Finding the leaking test
With VerifyTestMain, the check runs once at the end, so when it fails you know the package leaks and not which test. The README suggests a shell loop that builds the test binary once and runs each test individually, printing the names of those that fail. A variant that works in any shell:
go test -c -o tests
for t in $(go test -list . | grep -v '^ok'); do ./tests -test.run "^$t\$" >/dev/null 2>&1 || echo "leaks or fails: $t"; doneOnce you have the name, add defer goleak.VerifyNone(t) to that test (or the noLeaks helper above) to get the stack.
Choosing
| Situation | Use |
|---|---|
| Channel, timer and sync code; Go 1.25 | synctest.Test, no dependency, exact |
| Any code, a few tests, no dependencies wanted | the hand-written noLeaks helper |
| A package with many tests and real I/O | goleak.VerifyTestMain |
| Parallel tests | per-package check only |
Whichever you pick, make the check the default for packages that start goroutines, so a leak fails the build the day it is introduced. The goleak source and documentation are at github.com/uber-go/goleak and pkg.go.dev/go.uber.org/goleak, and the standard library's bubble testing is documented in testing/synctest.


