
Cory LaNou
Finding Goroutine Leaks with Go 1.27's Leak Profiler
Overview
Goroutine leaks are one of the sneakiest bugs in Go (golang). A goroutine blocks on a channel that nobody will ever read from, it sits there forever, and your memory slowly climbs until someone gets paged. The old way to find them was to stare at a full goroutine dump and guess which ones were stuck. Go 1.27 gives us something better. The runtime can now prove that certain goroutines can never wake up, and it reports them in a new goroutineleak profile. In this article, we'll write a leaky program, catch the leak with the new profiler, and fix it.
Target Audience
This article is aimed at developers that are comfortable with goroutines and channels.
In this article, we'll cover the following topics:
- What a goroutine leak is and how the most common one happens
- Detecting leaks with the new
goroutineleakprofile - How the runtime proves a goroutine is leaked
- Fixing the leak
- Using the profile in production over HTTP
The Most Common Leak in Go
Here's a pattern I see in almost every codebase. We call a slow backend in a goroutine, and we use a select with a context so the caller can time out:
func fetchPrice(ctx context.Context) (int, error) {
result := make(chan int)
go func() {
time.Sleep(50 * time.Millisecond) // simulate a slow backend
result <- 42 // blocks forever if nobody is listening
}()
select {
case price := <-result:
return price, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func main() {
fmt.Println("goroutines at start:", runtime.NumGoroutine())
for range 5 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
if _, err := fetchPrice(ctx); err != nil {
fmt.Println("err:", err)
}
cancel()
}
time.Sleep(100 * time.Millisecond)
fmt.Println("goroutines at end:", runtime.NumGoroutine())
}
$ go run .
goroutines at start: 1
err: context deadline exceeded
err: context deadline exceeded
err: context deadline exceeded
err: context deadline exceeded
err: context deadline exceeded
goroutines at end: 6
--------------------------------------------------------------------------------
Go Version: go1.27rc2
Notice that we started with 1 goroutine, and ended with 6? Every timed out call leaked one.
The problem is the unbuffered channel. When the timeout fires, fetchPrice returns through the ctx.Done() case, and nobody is left receiving on result. The worker goroutine finishes its sleep, tries to send, and blocks forever. The channel is unreachable by anything else in the program, so that send can never complete.
Five leaked goroutines in a demo is cute. Five leaked goroutines per second on a production API server is an incident.
The New goroutineleak Profile
Go 1.27 adds a new profile named goroutineleak, right alongside the profiles you already know like goroutine and heap. You can grab it in code with pprof.Lookup:
func fetchPrice(ctx context.Context) (int, error) {
result := make(chan int)
go func() {
time.Sleep(50 * time.Millisecond)
result <- 42
}()
select {
case price := <-result:
return price, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func main() {
for range 5 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
fetchPrice(ctx)
cancel()
}
time.Sleep(100 * time.Millisecond)
var buf bytes.Buffer
if err := pprof.Lookup("goroutineleak").WriteTo(&buf, 1); err != nil {
fmt.Println("profile error:", err)
return
}
printProfile(buf.String())
}
$ go run .
goroutineleak profile: total 5
5 @ [addresses trimmed]
# main.fetchPrice.func1 main.go:19
--------------------------------------------------------------------------------
Go Version: go1.27rc2
As you can see, the profile found all 5 leaked goroutines, and pointed at the exact line: the result <- 42 send inside fetchPrice. I didn't have to guess or dig through hundreds of healthy goroutines in a full dump.
Note: the raw profile includes memory addresses that change on every run, so I'm trimming them with a small printProfile helper to keep this example's output stable. You can see the full file in the repo. In real usage you'd feed the raw output to go tool pprof.
How the Runtime Knows
This is my favorite part. The leak detector rides along with the garbage collector. When you request the profile, the runtime asks a simple question about each blocked goroutine: is the thing it's blocked on (the channel, mutex, or wait group) still reachable from any goroutine that could actually run? If no runnable goroutine can reach the channel you're waiting on, nobody can ever send to it or receive from it. You are provably stuck.
This means no false positives from goroutines that are just waiting a long time. A goroutine blocked on a channel that a live goroutine still holds is fine and won't show up.
There is one caveat. If the blocking primitive is reachable through a global variable or a local in a running goroutine, the runtime can't prove it's dead, so some leaks can hide. Provable leaks get reported, unprovable ones don't.
Fixing the Leak
The fix for our timeout pattern is a one character change. Give the channel a buffer of one:
func fetchPrice(ctx context.Context) (int, error) {
// A buffer of one means the send always succeeds, even if
// we stopped waiting for the answer.
result := make(chan int, 1)
go func() {
time.Sleep(50 * time.Millisecond)
result <- 42
}()
select {
case price := <-result:
return price, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
$ go run .
goroutineleak profile: total 0
goroutines at end: 1
--------------------------------------------------------------------------------
Go Version: go1.27rc2
Notice that the profile now reports a total of 0, and we're back to a single goroutine at the end. With the buffer, the worker's send always succeeds even if nobody is listening. It sends, exits, and the abandoned channel gets garbage collected along with the value.
Using It in Production
For a long running service, you probably already import net/http/pprof. The new profile is exposed the same way as the rest:
# grab the leak profile from a running service
go tool pprof http://localhost:8080/debug/pprof/goroutineleak
There's also a human readable version if you just want to look:
curl http://localhost:8080/debug/pprof/goroutineleak?debug=1
Because detection runs a garbage collection cycle, don't poll this endpoint every second. Grab it when memory looks suspicious or as a periodic health check.
If you were following along in Go 1.26, this profile existed there too, but it was hidden behind GOEXPERIMENT=goroutineleakprofile at build time. In Go 1.27 it's on by default. And if you're already using goleak in your tests, keep it. The profile covers you in production where goleak can't go.
Summary
Goroutine leaks used to be a "stare at the dump and think hard" problem. Now the runtime does the thinking. It proves which goroutines can never wake up and hands you the exact line they're stuck on. Write the leaky pattern above into a test service, grab the profile, and watch it point straight at the bug. Then go check your real services.
Want More?
If you've enjoyed reading this article, you may find these articles interesting as well:


