How to Detect Goroutine Leaks with goleak in Tests

Use the `goleak` package to automatically detect and report any goroutines that remain running after your test function completes.

The silent resource drain

Your test suite passes. Every assertion checks out. The CI pipeline turns green. But over the next few weeks, the build takes longer. Memory usage climbs. Eventually, the runner hits an out-of-memory limit and crashes. The code itself never changed. The problem is invisible to the test framework: goroutines that started during a test never finished. They sit in the background, holding onto stack memory, open file descriptors, or database connections, waiting for a signal that will never arrive.

Go does not garbage collect goroutines. It only reclaims them when they exit. If a goroutine blocks on a channel, a mutex, or a network call without a cancellation path, it stays alive until the process dies. In production, this looks like a slow memory leak. In tests, it looks like flaky builds and exhausted CI runners.

Goroutines are cheap to spawn. They are expensive to forget.

What a goroutine leak actually is

A goroutine leak is not a bug in the Go runtime. It is a design gap in your concurrency model. Think of a goroutine like a worker in a factory. You hand them a task, they do the work, and they return to the break room when finished. If you forget to give them a way to stop, they stand at their workstation forever. They still take up floor space. They still hold the tools. The factory keeps running, but the overhead grows with every worker you spawn and forget to dismiss.

Go tracks every active goroutine internally. The runtime knows exactly how many are running, where they were created, and what they are blocked on. The goleak package simply asks the runtime for that list at the end of a test, compares it to a baseline, and panics if the numbers do not match. It turns an invisible resource drain into a loud, unignorable test failure.

The library does not modify your code. It does not inject hooks. It relies entirely on the runtime's built-in introspection. This makes it safe to add to existing test suites without rewriting your concurrency logic.

Leak detection belongs in the test harness, not in production code.

The baseline check

The simplest way to catch leaks is to attach a verification step to the end of your test function. You do not need to manually count goroutines or parse runtime metrics. The library handles the snapshot and comparison.

Here is the standard pattern for a single test function:

package mypkg

import (
	"testing"
	"time"

	"go.uber.org/goleak"
)

func TestWorkerStops(t *testing.T) {
	// Snapshot goroutines at test start and panic if any remain at exit
	defer goleak.VerifyNone(t)

	// Channel signals the goroutine to finish its work
	done := make(chan struct{})

	// Spawn a background worker that waits for the signal
	go func() {
		<-done
	}()

	// Simulate processing time before cleanup
	time.Sleep(10 * time.Millisecond)

	// Close the channel to unblock the waiting goroutine
	close(done)
}

The defer call runs after the test function returns, regardless of whether assertions passed or failed. goleak takes a snapshot of all goroutines when the test starts. When the deferred function executes, it takes a second snapshot. If the second list contains any goroutines that were not present in the first, the test fails with a stack trace pointing to the leak.

Modern Go tests often prefer t.Cleanup over defer for setup and teardown logic. t.Cleanup registers functions that run in reverse order after the test completes, which matches the execution order of test helpers. goleak works identically with both patterns. Choose whichever aligns with your project's testing conventions.

Always verify at the end, not in the middle.

How the verification runs

When goleak.VerifyNone executes, it calls runtime.AllGoroutines under the hood. This returns a slice of runtime.Goroutine structs, each containing the goroutine ID, current state, and stack trace. The library filters out known runtime goroutines like the garbage collector, the signal handler, and the test runner itself. It then compares the remaining list against the baseline.

If a goroutine is still alive, goleak prints its full stack trace. You will see exactly which function created it, which file and line number spawned it, and what channel or lock it is waiting on. This removes the guesswork from debugging concurrency. You do not need to add print statements or attach a debugger. The test output tells you where the worker got stuck.

The library also handles a subtle timing issue. Goroutines take a few microseconds to exit after receiving a cancellation signal. If goleak checks immediately, it might catch a goroutine that is in the process of shutting down. The library includes a short grace period by default. It waits a few milliseconds, checks again, and only fails if the goroutine is still present. This prevents false positives from normal shutdown latency.

The library respects Go's testing conventions. It uses t.Fatal to stop the test immediately when a leak is found. This prevents subsequent tests from inheriting the leaked goroutines and masking the original problem. If you prefer to collect multiple failures in one run, you can pass a custom goleak.Option that uses t.Error instead, but failing fast is usually the right choice for resource leaks.

Read the stack trace from bottom to top to find the source.

Real-world test scaffolding

Individual test checks work well for isolated functions. Larger packages often need a suite-wide guard. Global initialization code, package-level init functions, or shared test fixtures can spawn background goroutines that outlive individual test functions. A suite-wide check catches those early.

Here is how you wire it into the test runner:

// main_test.go
package mypkg

import (
	"testing"

	"go.uber.org/goleak"
)

// TestMain runs before and after all tests in the package
func TestMain(m *testing.M) {
	// Verify no leaked goroutines remain after the entire suite finishes
	goleak.VerifyTestMain(m)
}

VerifyTestMain takes the *testing.M runner, executes m.Run() to run all tests, and then performs the leak check. It wraps the entire package execution in a single verification boundary. This is the standard place to put it in Go projects that care about resource hygiene.

Some tests legitimately require long-running background processes. A test that spins up an HTTP server, a database mock, or a message queue listener will always have a goroutine waiting for connections. You do not want goleak to fail on those. The library provides an ignore list that matches against the top function in a goroutine's stack trace.

func TestServerLifecycle(t *testing.T) {
	// Ignore the standard library HTTP server listener goroutine
	defer goleak.VerifyNone(t, goleak.IgnoreTopFunction("net/http.(*Server).Serve"))

	// Start a test server that runs until the test ends
	srv := &http.Server{Addr: "localhost:0"}
	go srv.ListenAndServe()
	defer srv.Close()

	// Run assertions against the server
}

The ignore list matches the exact function name string. If the stack trace shows net/http.(*Server).Serve at the top, goleak skips that goroutine during comparison. You can chain multiple ignore options for complex fixtures. The convention in Go testing is to keep ignore lists as narrow as possible. Broad ignore patterns hide real leaks and defeat the purpose of the check.

Ignore only what you control. Never ignore the standard library unless you are testing it.

Where leaks hide and how to catch them

Goroutine leaks usually stem from three patterns: unclosed channels, missing context cancellation, and abandoned timers. Each one leaves a worker stuck in a blocking call.

Channels are the most common culprit. If you spawn a goroutine that reads from a channel, but the sender never closes it, the reader blocks forever. The runtime prints all goroutines are asleep - deadlock! when the entire program halts, but in a test environment, the goroutine just sits there until goleak catches it. Always close channels when the sender is finished. If multiple goroutines write to the same channel, use a sync.Once wrapper to prevent a panic on double close.

Context cancellation is the second trap. Go's context package is the standard way to signal shutdown across concurrent code. If you pass a context to a goroutine but forget to call the cancel function, the goroutine waits on a channel that will never receive a value. The compiler does not check for missing cancellations. You get a silent leak. The fix is straightforward: always call defer cancel() immediately after context.WithCancel or context.WithTimeout. Treat the cancel function like a resource release.

Timers and tickers leak in a different way. time.NewTimer and time.NewTicker spawn internal goroutines to handle the scheduling. If you stop the test without calling Stop(), the internal goroutine continues firing until the timer expires or the process ends. goleak will flag it as a leak originating in the time package. Call timer.Stop() or ticker.Stop() in a defer block to reclaim the background worker.

When goleak reports a leak, read the stack trace from top to bottom. The top frame shows where the goroutine is currently blocked. The lower frames show where it was created. Match the creation point to your test code. Add the missing close, cancel, or stop call. Run the test again. The leak disappears.

The compiler will not save you from concurrency bugs. The test harness will.

When to use goleak versus manual checks

Use goleak.VerifyNone when you want a zero-configuration guard on individual test functions. Use goleak.VerifyTestMain when you need suite-wide coverage that catches leaks in package initialization or shared fixtures. Use goleak.IgnoreTopFunction when your test legitimately spawns long-running servers or daemons that must stay alive during assertions. Use manual runtime.NumGoroutine() checks when you are writing a custom test harness that needs precise numeric thresholds instead of strict equality. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.

Goroutines are cheap. Leaks are not. Catch them in tests before they reach production.

Where to go next