Cory LaNou

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 goroutineleak profile
  • 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 &lt;- 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:

Ready to level up your Go?

25+ years of combined Go expertise. Fortune 500 clients. Real results.

Go Training

Instructor-led courses—in-person, virtual, or self-paced—that transform your team.

Get Training

Consulting

Expert guidance when you need it—architecture, code review, best practices.

Start Consulting

Code Audit

Deep dive into your codebase. Find issues before they find you.

Request Audit

More Articles