The go1.27 Goroutine Leak Profile: What Reachability Can and Cannot See
go1.27 adds a goroutineleak profile to runtime/pprof that finds goroutines blocked on unreachable primitives. I built three fixtures: two it catches, and one identical leak it cannot see, because a package-level map still holds the channel.

Written and benchmarked against
go1.27rc1, cut fromrelease-branch.go1.27on 18 June 2026. The release notes carried a draft warning at the time of writing. Final release is 25 August 2026.
I built the same goroutine leak twice. One copy gets reported by the new profile in go1.27. The other, identical in every way that matters to the process, does not. The only difference between them is one boolean, and what that boolean actually controls is not the bug. It is whether a package-level map still holds a pointer to the channel the bug leaves dangling. That single fact is most of what I want this post to leave you with, because it is the part a changelog entry will not tell you.
The problem with "list every goroutine"
Go has always let you enumerate goroutines: runtime.Stack with all=true, or the goroutine profile that net/http/pprof has served since forever. What it has never told you is which of those goroutines are actually stuck. A production process with two thousand goroutines might have eighteen hundred doing real work and two hundred blocked forever on a channel nobody will ever send to again. The goroutine profile shows you all two thousand with equal weight. Finding the two hundred means reading stack traces by eye, or reaching for go.uber.org/goleak in a test, which needs you to already suspect a specific test is leaking.
go1.27 adds a profile that tries to answer the actual question: which of these goroutines cannot ever be unblocked. It is registered under runtime/pprof as goroutineleak, and because net/http/pprof dispatches any registered profile by name, it shows up at /debug/pprof/goroutineleak with no new code in your server at all.
How the detector decides
The mechanism is runtime/pprof.writeGoroutineLeak, which runs a dedicated GC cycle with leak detection turned on (runtime_goroutineLeakGC). That cycle does four things, in src/runtime/mgc.go:
setSyncObjectsUntraceablemarks every blocked goroutine'ssudog(its wait record) untraceable, so the garbage collector cannot reach a channel or mutex by walking through a parked goroutine.- Marking starts from the ordinary roots: globals, plus the stacks of goroutines already known to be runnable. A blocked goroutine's own stack is not in this initial root set.
findMaybeRunnableGoroutines(mgc.go:1204) repeatedly asks each blocked goroutine whether the primitive it is parked on got marked, via(*g).isMaybeRunnable(mgc.go:1162). For a channel wait that check isisMarkedOrNotInHeap(sg.c); for a mutex, wait group or cond it checkssg.waiting.elem. If the primitive is marked, somebody reachable could still signal it, so the goroutine is promoted to maybe-runnable and its own stack joins the root set, which can promote further goroutines in turn. This repeats to a fixed point.findGoroutineLeaks(mgc.go:1278) takes whatever never got promoted and moves it from_Gwaitingto_Gleaked.
The detector is answering a reachability question, not a liveness one: is the thing this goroutine is waiting on reachable from anywhere that could still touch it. That is a sound way to avoid false positives. It also means a goroutine blocked forever on a channel that any global still references is not reported, no matter how permanently it is actually stuck. That is not an edge case I went looking for. It is the direct, unavoidable consequence of deciding on reachability, and it is worth building a fixture around rather than taking on faith.
Three fixtures, one blind spot
I wrote three scenarios shaped like production bugs, in 03-goroutine-leak in the companion repo. Two get reported. One does not, and the one that does not is the point.
| Fixture | Bug | Reported? |
|---|---|---|
| Ingest pipeline | The consumer returns on the first bad record, stranding every worker mid-send on the results channel. | Yes, 7 goroutines |
| Session store | Drain returns early without unlocking, so the background reaper blocks on the mutex forever. | Yes, 1 goroutine |
| Backend dispatcher | The transport failure path returns without sending on the reply channel, and forgets to drop the correlation ID from a package-level registry. | No |
Detected: a pipeline that loses its consumer
leaks/pipeline.go fans eight workers out over a jobs channel and back in over a results channel, with a single consumer draining results. The consumer bails out on the first error instead of draining the rest:
var passed int
for range records {
res := <-results
if res.Err != nil {
// BUG: returning here strands every worker that has a result to
// hand back. The fix is to cancel the workers (a done channel or a
// context) and drain results before returning.
return passed, fmt.Errorf("validating %s: %w", res.ID, res.Err)
}
passed++
}Every record in the fixture batch has an empty payload, so the first result to arrive is an error and the consumer returns immediately. Eight workers were sending; one of them had already delivered its result and found the jobs channel closed, so it exits cleanly. The other seven are permanently parked on results <- validate(rec), and once Run itself has returned, nothing reachable references either channel. go tool pprof's text rendering at debug=1:
goroutineleak profile: total 7
7 @ 0x102ec8fc8 0x102e633c4 0x102e62fd8 0x102f23470 0x102ecf314
# 0x102f2346f github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/leaks.(*Pipeline).Run.func1+0x6f /Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/leaks/pipeline.go:56Detected: a reaper blocked on a mutex, not a channel
leaks/sessionstore.go shows the detector covers sync primitives, not only channels. Drain's early return skips the unlock the happy path relies on:
func (s *SessionStore) Drain() error {
s.mu.Lock()
if s.closed {
// BUG: no s.mu.Unlock() before this return. The mutex stays held for
// the rest of the process's life.
return ErrStoreClosed
}
defer s.mu.Unlock()
s.closed = true
clear(s.sessions)
return nil
}The fixture calls Drain twice, the way a shutdown path listening for SIGTERM and also running a deferred cleanup would. The second call hits the held-open branch, and the background reaper, already waiting on that mutex in its own goroutine, waits forever. The debug=2 stack dump names the wait reason explicitly:
goroutine 8 [sync.Mutex.Lock (leaked)]:
internal/sync.runtime_SemacquireMutex(0x0?, 0x0?, 0x7343131076d8?)
/Users/ajitem/sdk/go1.27rc1/src/runtime/sema.go:95 +0x28
internal/sync.(*Mutex).lockSlow(0x7343130fc080)
/Users/ajitem/sdk/go1.27rc1/src/internal/sync/mutex.go:149 +0x174
internal/sync.(*Mutex).Lock(...)
/Users/ajitem/sdk/go1.27rc1/src/internal/sync/mutex.go:70
sync.(*Mutex).Lock(...)
/Users/ajitem/sdk/go1.27rc1/src/sync/mutex.go:46
github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/leaks.(*SessionStore).reap(0x7343130fc080)
/Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/leaks/sessionstore.go:77 +0x98
created by github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/leaks.NewSessionStore in goroutine 7
/Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/leaks/sessionstore.go:38 +0xc4Not detected: a channel a registry still holds
leaks/dispatcher.go is the fixture that matters. Dispatcher.Do registers a reply channel under a correlation ID in a package-level inflight map before handing the request to a call goroutine, exactly the shape you would want for a multiplexed transport where a socket-reading goroutine has only a correlation ID to find the right channel with:
var inflight = struct {
mu sync.Mutex
m map[string]chan Response
}{
m: make(map[string]chan Response),
}
func (d *Dispatcher) Do(req Request) (Response, error) {
reply := make(chan Response)
inflight.mu.Lock()
inflight.m[req.CorrelationID] = reply
inflight.mu.Unlock()
go d.call(req, reply)
// BUG: no context, no deadline, no default case. If call never delivers,
// this receive is permanent.
return <-reply, nil
}call's failure path returns without ever sending on reply and without closing it, so Do blocks on <-reply for the rest of the process's life:
func (d *Dispatcher) call(req Request, reply chan<- Response) {
resp, err := d.Transport(req)
if err != nil {
if d.ForgetOnFailure {
forget(req.CorrelationID)
}
// BUG: the failure path returns without sending anything on reply and
// without closing it. The caller waits forever.
return
}
forget(req.CorrelationID)
reply <- resp
}ForgetOnFailure controls only whether that failure path also drops the correlation ID from inflight. The leak itself is identical either way. Running both variants with -inflight, which prints the registry size right before the profile is written, shows the whole story in two lines:
leakfixture: inflight=1
goroutineleak profile: total 0leakfixture: inflight=0
goroutineleak profile: total 1
1 @ 0x1021d8fc8 0x102174280 0x102173dd4 0x102232380 0x1022332e0 0x1021df314
# 0x10223237f github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/leaks.(*Dispatcher).Do+0x1ef /Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/leaks/dispatcher.go:96
# 0x1022332df github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/leaks.leakDispatcher.func1+0x8f /Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/leaks/dispatcher.go:155Apply the four detection steps to this case and the result stops being surprising. After call returns, the only remaining references to reply are Do's own blocked stack frame, the channel's sudog in its receive queue (deliberately hidden from marking at step 1), and inflight.m. inflight is a package-level variable, so it is a root at step 2. reply gets marked through it, isMaybeRunnable reports the blocked Do goroutine as maybe-runnable at step 3, and it never reaches step 4. Drop the registry entry, as the ForgetOnFailure variant does, and nothing outside the blocked goroutine's own frame references reply any more. The goroutine is identically stuck. It is now unreachable, and it gets reported.
The undetected goroutine has not gone anywhere. At debug=2 the same run shows it parked on the same channel receive, without the (leaked) tag the reported variant carries:
goroutine 7 [chan receive]:
github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/leaks.(*Dispatcher).Do(0x176d81962090, {{0x176d8194e108, 0x11}, {0x100ad0223, 0x3}, {0x100ad24ce, 0x11}})
/Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/leaks/dispatcher.go:96 +0x1f0
github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/leaks.leakDispatcher.func1()
/Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/leaks/dispatcher.go:155 +0x90Against the deregistered variant, same goroutine, same frame:
goroutine 7 [chan receive (leaked)]:A correlation-ID map is not a contrived shape to build a fixture around. A sync.Map of subscribers, a metrics registry keyed by request, a debug endpoint that keeps hold of in-flight work, all root the primitive the same way and take the blocked goroutine off the profile. That is the class of leak this tool will not find for you, and it will not tell you it missed one.
I want to be precise about what this is, because it would be easy to read it as a defect in the rc1 implementation. It is not. The runtime's own test suite carries a fixture, NoLeakGlobal (src/runtime/testdata/testgoroutineleakprofile/simple.go:265), that asserts this exact behaviour: a goroutine blocked on a primitive still reachable from a global must not be reported as leaked. The people who built the detector chose reachability specifically to keep the false-positive rate at zero, and a global-rooted registry is the price of that choice. Treat it as a documented property of the tool, and design around it, rather than expecting the profile to catch what a registry is actively hiding from it.
Serving it over HTTP
service/service.go registers the standard net/http/pprof handlers on an explicit http.ServeMux instead of importing the package for its side effect, which keeps the profile off http.DefaultServeMux:
func NewMux() *http.ServeMux {
mux := http.NewServeMux()
// Application routes.
mux.HandleFunc("GET /healthz", healthz)
mux.HandleFunc("GET /demo/leak", demoLeak)
// pprof routes. Index serves the listing at /debug/pprof/ and also
// dispatches every named profile below it, including goroutineleak.
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("POST /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
return mux
}No goroutineleak-specific wiring is needed: pprof.Index dispatches any registered runtime/pprof profile by name, and go1.27 registers goroutineleak alongside goroutine, heap and the rest. Start it and seed two leaked goroutines:
GOTOOLCHAIN=local go1.27rc1 run ./03-goroutine-leak/cmd/leakservice
curl -s 'http://localhost:6060/demo/leak'
curl -s 'http://localhost:6060/debug/pprof/goroutineleak?debug=1'Real output from that pair of commands:
goroutineleak profile: total 2
1 @ 0x1009009d8 0x100891714 0x100891328 0x100ab08d8 0x100907d54
# 0x100ab08d7 github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/service.SeedLeak.func2+0x27 /Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/service/service.go:94
1 @ 0x1009009d8 0x1008925a0 0x100892124 0x100ab0914 0x100907d54
# 0x100ab0913 github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/service.SeedLeak.func1+0x23 /Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/service/service.go:88go tool pprof consumes the protobuf form and reports the profile type as goroutineleak:
GOTOOLCHAIN=local go1.27rc1 tool pprof -top 'http://localhost:6060/debug/pprof/goroutineleak'File: leakservice
Type: goroutineleak
Time: 2026-08-13 18:49:35 IST
Showing nodes accounting for 2, 100% of 2 total
Showing top 6 nodes out of 7
flat flat% sum% cum cum%
2 100% 100% 2 100% runtime.gopark
0 0% 100% 1 50.00% github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/service.SeedLeak.func1
0 0% 100% 1 50.00% github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/service.SeedLeak.func2
0 0% 100% 1 50.00% runtime.chanrecv
0 0% 100% 1 50.00% runtime.chanrecv1
0 0% 100% 1 50.00% runtime.chansendLessons learned: four things that will trip you up
I read the release notes' description of this profile before I built any of the fixtures, then checked every claim against go1.27rc1 source and captured output. Four gaps are worth carrying into anything you build on top of this.
1. ?debug=2 is not filtered to leaked goroutines, despite what the docs say. Both the runtime/pprof package doc and the net/http/pprof index describe the profile as stack traces of all leaked goroutines. At debug >= 2, writeGoroutineLeak falls through to writeGoroutineStacks (src/runtime/pprof/pprof.go:803), which dumps every goroutine in the process. Leaked ones are marked only by (leaked) appended to the wait reason in the header, for example goroutine 8 [sync.Mutex.Lock (leaked)]:. My service capture has 8 goroutines in the debug=2 body and 2 leaked ones among them. Anything parsing that dump has to grep for the tag; only debug=0 and debug=1 contain leaked goroutines alone.
2. Profile.Count() is stale, not live. It returns work.goroutineLeak.count (src/runtime/proc.go:5771), and that field is only written by a GC cycle that ran leak detection, which only WriteTo ever schedules. A /metrics handler that exports Count() on a timer reads 0 forever on a process that is genuinely leaking, right up until something actually writes the profile:
leakfixture: count-before-profile=0
goroutineleak profile: total 7
7 @ 0x102358fc8 0x1022f33c4 0x1022f2fd8 0x1023b3470 0x10235f314
# 0x1023b346f github.com/asahasrabuddhe/go-1-27-bench/03-goroutine-leak/leaks.(*Pipeline).Run.func1+0x6f /Users/ajitem/go-1-27-bench-wt/03-goroutine-leak/03-goroutine-leak/leaks/pipeline.go:56
leakfixture: count-after-profile=7Seven goroutines were already stranded when the process started, and Count() still read 0 until WriteTo ran. Treat it as a record of the last profile taken, not a gauge you can scrape.
3. Taking the profile forces at least one full GC cycle, serialised under a lock. goroutineLeakGC loops on GC() until a cycle actually picks up the pending flag, and writeGoroutineLeak holds a lock across the whole request, so concurrent scrapes queue up behind each other. I have not measured what this costs on a large heap; nothing in this directory's results produces a timing number, so I am not going to put one on it here. Treat it as a profile you pull deliberately, not one you poll on a tight interval.
4. The blind spot is deliberate, not a bug. I said this above and it is worth repeating on its own: the runtime ships NoLeakGlobal, its own fixture, asserting that a goroutine blocked on a globally-reachable primitive must not be reported. This is a documented design trade-off in favour of zero false positives, not an oversight to file an issue about.
None of this makes the profile a replacement for go.uber.org/goleak in tests or for -race on data races. It answers a narrower question than either: given the state of the heap right now, which blocked goroutines are provably unreachable.
Takeaways
The goroutineleak profile in go1.27 finds goroutines blocked on primitives that nothing reachable can ever signal, and it does that well: pipeline and sessionstore both got caught with exact, repeatable counts. It does not find every stuck goroutine, and it should not be read as though it does. A registry, a subscriber map, a cache keyed by request, anything package-level that still holds the channel or mutex a goroutine is parked on, keeps that goroutine off the report even though it is exactly as stuck as the ones the profile does list.
Read ?debug=2 output with the tag, not the goroutine count, as your signal. Do not poll Count() expecting a live number. Do not pull this profile on a schedule you would use for /metrics. And when a leak you know is there does not show up, check what still references the primitive before you conclude the detector missed it: in every case I built, it hadn't missed anything, it had correctly decided the primitive was still reachable.
The full set of fixtures, tests and captured output lives in 03-goroutine-leak in the companion repo, including the earlier, broken version of the dispatcher fixture that tried to deregister with a defer inside Do itself and silently proved nothing, because a deferred cleanup in a function that never returns never runs.