How can a Go test detect that the code it exercised left a goroutine still running?
answer
- count them, then count them again
- the test binary just exits, silently
- a number starts an investigation
- runtime/pprof has a goroutine profile
- stacks name the parked call site
basics
~20 sSample runtime.NumGoroutine() before the exercised code and again after it should have shut down; a higher number means something never exited. Dumping the runtime/pprof goroutine profile instead of counting also shows the leftover stacks, so you learn where.
solid answer
~40 sThe test binary exits when the last test finishes, so nothing fails on its own — you have to ask the runtime what is still alive. The cheap version is `runtime.NumGoroutine()`, sampled before you exercise the code and again after you have shut it down; if the second number is higher, something the code started never returned. The better version is the goroutine profile from `runtime/pprof`: `pprof.Lookup("goroutine")` gives you the same count via `Count()`, and `WriteTo(w, 1)` or `WriteTo(w, 2)` prints the remaining stacks, so a failure names the call site the goroutine is parked on instead of just a number. In a fan-out service that holds one subscription goroutine per client, that dump is the difference between "three goroutines are missing" and "three are blocked on a channel send nobody receives".
code
go · 14 linesfunc TestSubscriptionStopsItsGoroutine(t *testing.T) {
before := runtime.NumGoroutine()
hub := newHub()
sub := hub.subscribe()
sub.unsubscribe()
hub.shutdown()
if after := runtime.NumGoroutine(); after > before {
buf := make([]byte, 1<<16)
n := runtime.Stack(buf, true)
t.Fatalf("goroutines %d -> %d\n%s", before, after, buf[:n])
}
}go deeper
Be ready to say that nothing fails automatically — the test binary exits and parked goroutines disappear — and to name the two ways to look: runtime.NumGoroutine for a count, the runtime/pprof goroutine profile for the stacks.
Explain what the number actually covers: every goroutine in the process, including the testing framework's and the standard library's. Be able to say why printing stacks beats printing a count, and what debug levels 1 and 2 give you.
Show where you would spend this check in a real suite — around the components that own long-lived goroutines — and make the failure output actionable. Be honest that a clean run only covers the path the test took.
Frame it as an expected cost: a leak that a test suite cannot see is paid for later by whoever is restarting the service for memory. Argue for leak checks on the few components that own goroutines rather than a suite-wide mandate nobody maintains.
## What "leaked" means inside a test A leaked goroutine is one the code under test started and that never returns. The Go runtime never reclaims a live goroutine: it keeps the goroutine's stack, and it keeps everything reachable from that stack alive as far as the garbage collector is concerned. In a test that costs nothing visible, because the test binary calls `os.Exit` once the last test finishes and every goroutine still parked simply vanishes with the process. **A package can be one hundred percent green and still leak.** That asymmetry is the whole reason this check exists. Consider a fan-out service that holds one long-lived subscription per connected client, each served by its own goroutine with its own per-subscriber buffer. If unsubscribing leaves the goroutine parked on a send nobody receives, the test suite notices nothing, and the service accumulates one goroutine plus one buffer per abandoned client until it is restarted for running out of memory. The person writing that postmortem wants to know why the test suite never said a word. ## The count: runtime.NumGoroutine `runtime.NumGoroutine() int` returns how many goroutines currently exist **in the whole process**. The usual shape is: 1. sample the number before you construct the thing under test, 2. exercise it and shut it down, 3. sample again and compare. Two properties matter. First, it is process-wide: it includes the goroutines `testing` runs your test on, goroutines the runtime and the standard library have started, and anything another test left behind. It is not a count of "goroutines my code started". Second, it is a single scalar with no attribution — it can tell you that something is left, never what. ## The dump: the goroutine profile from runtime/pprof `pprof.Lookup("goroutine")` returns a `*pprof.Profile`. `Count()` gives the same number `runtime.NumGoroutine()` does; `WriteTo(w io.Writer, debug int) error` prints the goroutines themselves: - `debug = 1` aggregates goroutines with identical stacks and prints a readable count-per-stack — ideal when a leak has produced two hundred copies of the same goroutine. - `debug = 2` prints one full stack per goroutine in the same layout an unrecovered panic uses, including each goroutine's state (`chan receive`, `chan send`, `select`, `IO wait`, `semacquire`) and, once it has been waiting a while, how long. `runtime.Stack(buf []byte, all bool) int` is the equivalent with no package to import: pass `true` and it writes every goroutine's stack into your buffer. The dump is what turns an assertion into a bug report. "Goroutines went from 6 to 9" starts an investigation; "three goroutines are parked on `chan send` inside the broadcast loop" ends one. ## Where the check goes The simplest placement is inside the test itself: take the baseline at the top, `defer` the comparison, and fail with the dump attached. That gives you exact attribution — you know which test leaked. A suite-wide check placed around the run in `TestMain` is cheaper but tells you only that the package leaked. ## What the check does not prove - **A clean run proves nothing about paths the test did not take.** This is an observation of one execution, not a static analysis. - **The race detector does not report leaks.** `-race` finds unsynchronised accesses that actually happened; a goroutine blocked forever is perfectly race-free. - **A count that matches is not proof that the same goroutines are alive.** One goroutine exiting while another leaks nets to zero. Comparing stacks rather than numbers closes that hole. - **Goroutines do not exit the instant you tell them to.** A count read immediately after shutdown can be high simply because the goroutine has not been scheduled to run its last statement yet, which is why a naive equality assertion flakes and why the comparison is normally retried for a short while before it fails. ## The habit worth forming Check the goroutine count or profile around the code that owns long-lived goroutines — subscriptions, background pollers, connection readers — and print stacks, not numbers, when it fails. Those are the places where a leak is both likely and expensive, and they are a small enough set that the check stays cheap.
- Why prefer the goroutine profile over comparing counts?A count is one scalar with no attribution: it says three goroutines are left, not which ones. The profile from `pprof.Lookup("goroutine")` prints their stacks — `WriteTo(w, 1)` grouped by identical stack, `WriteTo(w, 2)` one full stack each, with the state each goroutine is parked in. That turns a failing assertion into the call site to fix, and it also catches the case where one goroutine exits while another leaks so the totals happen to match.
- Which goroutines does runtime.NumGoroutine() include?Every goroutine that currently exists in the process. That covers the goroutine `testing` is running your test on, goroutines the runtime keeps for itself, background goroutines the standard library started lazily on first use, and anything a previously run test left behind. It is a process-wide number, not a per-test one, so the baseline you compare against has to be taken in the same process at a comparable moment.
- Does running the tests with -race report leaked goroutines?No. The race detector instruments memory accesses and reports two goroutines touching the same memory without synchronisation, on paths the run actually executed. A goroutine parked forever on a channel operation performs no unsynchronised access at all, so it is invisible to `-race`. Leak detection is a separate check: count or profile the goroutines yourself, or let the test-binary timeout dump every stack when the leak also blocks the test.
Counting the people left in the building tells you somebody stayed late. The security-camera still tells you who it is and which door they are standing at.
saying these in an interview costs you the question
- Assumes a passing test proves its goroutines exited
- Thinks the runtime reports leaked goroutines at process exit
- Believes the race detector finds goroutine leaks
- Reads runtime.NumGoroutine as counting only this test's goroutines
- Reports a bare count with no stacks attached