Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /Go 1.26 Goroutine Leak Detection: A Practical Guide
devops11 min read

Go 1.26 Goroutine Leak Detection: A Practical Guide

Go 1.26 ships a real goroutine leak profiler. Here's how to enable it, read the profile output, and catch leaks in production before OOMKilled does it for you.

Zeeshan Tofiq
Zeeshan Tofiq
July 18, 2026
On this page

On this page

  • The Leak That Doesn't Panic
  • The Old Ways, and Why They Fall Short
  • What Actually Shipped in Go 1.26
  • A Working Leak, Start to Finish
  • Reading the Profile Output
  • The Kubernetes Payoff
  • What It Still Can't Catch
  • A Workflow You Can Actually Run This Week
  • What's Next
  • Frequently Asked Questions

Short answer: Build with GOEXPERIMENT=goroutineleakprofile, hit /debug/pprof/goroutineleak?debug=2, and look for the (leaked) tag in the stack trace. Go's garbage collector already knows a goroutine can never wake up (blocked on an unreachable channel, mutex, or condition variable) and Go 1.26 is the first release that exposes that fact as a profile instead of making you guess.

Your Go service is fine. Then it isn't. Memory creeps up over a few days, nothing crashes, no error logs, and eventually a pod gets OOMKilled and restarts. You bump the memory limit and move on. Three weeks later it happens again.

The Leak That Doesn't Panic

That's usually a goroutine leak, and Go has historically been terrible at telling you about it. There's no panic. No warning. No -race flag for it. You can leak a million goroutines and the runtime will happily let you, because from Go's point of view, a goroutine blocked forever isn't an error. It's just waiting.

Each leaked goroutine costs at least 2KB of stack space plus whatever it's holding in its closure: an open database connection, a large slice, a reference to a struct that should have been garbage collected an hour ago. One leaked goroutine is noise. A steady trickle of a few per request, running for days inside a Kubernetes pod with a fixed memory limit, is exactly the kind of slow-burn failure that's miserable to diagnose after the fact, because by the time you notice, the evidence is buried under thousands of other goroutines that are behaving completely normally.

The Old Ways, and Why They Fall Short

If you've dealt with this before, you've probably reached for Uber's goleak library in your tests, or pulled a manual goroutine dump off /debug/pprof/goroutine. Both are useful, and neither solves the real problem.

go — main_test.go
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}

func TestWorker(t *testing.T) {
    defer goleak.VerifyNone(t)
    // ...test body
}

goleak snapshots the goroutines running before and after a test and fails if new ones are still alive when the test ends. It's a solid first line of defense, but it only catches leaks that happen during a test run, on a code path your test actually exercises. It has nothing to say about a service that's been running in production for six days under real traffic patterns your test suite never simulated.

The other classic move is pulling a raw goroutine dump from /debug/pprof/goroutine?debug=2 and eyeballing it. That endpoint has existed since Go 1.x and it gives you a list of every goroutine that exists right now, leaked or not. A healthy service under load might have hundreds of legitimately busy goroutines. Manually scanning stack traces trying to guess which ones are stuck forever versus which ones are just slow is exactly the kind of task humans are bad at and computers are good at, which is the gap Go 1.26 closes.

What Actually Shipped in Go 1.26

Go 1.26 shipped an experimental profile type called goroutineleak in the runtime/pprof package. It's not a timer and it's not a heuristic. It reuses the garbage collector's own reachability analysis: if a goroutine is blocked on a channel, mutex, or condition variable, and that object is unreachable from anything that could ever unblock it, the runtime knows for a fact that goroutine can never wake up. That's a proven leak, not a guess based on how long something has been blocked.

This matters because it's the same class of insight -race gives you for data races: not "this looks suspicious," but "this is mathematically true given the current object graph." That's what makes it safe to trust in a report instead of treating it as another noisy signal to filter.

Enable it at build time with the GOEXPERIMENT environment variable:

bash — terminal
GOEXPERIMENT=goroutineleakprofile go build -o myservice ./cmd/myservice

Once enabled, the profile shows up as a normal pprof endpoint at /debug/pprof/goroutineleak, the same family as the existing heap, goroutine, and allocs profiles you're probably already using. You can also pull it programmatically:

go — diagnostics.go
import "runtime/pprof"

func dumpLeaks(w io.Writer) error {
    p := pprof.Lookup("goroutineleak")
    if p == nil {
        return errors.New("goroutineleak profile unavailable; build with GOEXPERIMENT=goroutineleakprofile")
    }
    return p.WriteTo(w, 2)
}

ℹ It's still experimental

goroutineleakprofile sits behind GOEXPERIMENT for a reason. It's off by default in Go 1.26, so existing builds aren't affected unless you opt in. Treat it as a diagnostic tool you deliberately turn on for a service you're investigating, not something you flip on globally without testing first.

A Working Leak, Start to Finish

Here's a function that fans out work across goroutines and collects results on an unbuffered channel, a pattern that shows up constantly in real codebases (batch processing, parallel API calls, worker pools):

go — gather.go
func Gather(funcs ...func() int) <-chan int {
    out := make(chan int)
    for _, f := range funcs {
        go func() {
            out <- f() // blocks forever if nobody reads
        }()
    }
    return out
}

func main() {
    out := Gather(taskA, taskB, taskC)
    first := <-out // only reads one result
    fmt.Println(first)
    // taskB and taskC's goroutines are now stuck on "out <- f()" forever
}

If the caller only reads the first result and moves on, every other goroutine is stuck forever on out <- f(), because nobody is ever going to read from out again and the channel is unbuffered. Nothing crashes. Nothing logs. Each one just sits there permanently, holding onto whatever memory it captured in its closure, whether that's a large response body, an open file handle, or a database row set.

This exact shape (fan-out over an unbuffered channel, early return from the caller) is one of the most common leak patterns in real Go services, because it's invisible in code review. The function looks correct. It even works correctly for the happy path where the caller reads every value. It only leaks when the caller doesn't, and that's often a behavior introduced months later by a different engineer touching the calling code, not the Gather function itself.

Reading the Profile Output

Run the program above under the profiler and pull the profile with debug=2 for full stack traces (debug=1 gives you a condensed summary count per unique stack, useful for a quick glance; debug=2 gives you the full trace per goroutine, which is what you want when you're actually hunting a leak):

bash — terminal
$ curl -s "http://localhost:6060/debug/pprof/goroutineleak?debug=2"
goroutine profile: total 3
goroutine 21 [chan send (leaked)]:
main.Gather.func1()
	/app/gather.go:5 +0x3c
created by main.Gather in goroutine 1
	/app/gather.go:4 +0x8a

goroutine 22 [chan send (leaked)]:
main.Gather.func1()
	/app/gather.go:5 +0x3c
created by main.Gather in goroutine 1
	/app/gather.go:4 +0x8a

That (leaked) tag is the whole point. A normal goroutine profile would show [chan send] for a goroutine that's momentarily blocked waiting for someone to read, which is completely normal and happens constantly in healthy code. The goroutineleak profile only lists a goroutine with (leaked) when the reachability analysis has proven the channel it's blocked on can never be read from again. That distinction, provably stuck versus temporarily busy, is what turns this from noise into an actionable report you can hand to whoever owns that code.

The Kubernetes Payoff

A single leaked goroutine doesn't matter. A slow, steady leak that adds a few more every request cycle absolutely does, and in Kubernetes it shows up as a pod that runs fine for hours then gets OOMKilled on a schedule that roughly tracks your traffic pattern. If you've spent time troubleshooting CrashLoopBackOff and OOMKilled pods before, this is the same failure mode with a specific, provable root cause instead of "memory usage is high, we don't know why."

Never expose /debug/pprof/* publicly. Wire it behind an internal-only Service so only cluster-internal traffic (or a port-forward) can reach it:

yaml — service.yaml
apiVersion: v1
kind: Service
metadata:
  name: myservice-debug
  labels:
    app: myservice
spec:
  type: ClusterIP
  selector:
    app: myservice
  ports:
    - name: pprof
      port: 6060
      targetPort: 6060
# Reach it with: kubectl port-forward svc/myservice-debug 6060:6060

With that in place, pull the profile before and after a load test, or on a quiet night versus a busy one, and diff the leaked-goroutine count between the two:

bash — terminal
kubectl port-forward svc/myservice-debug 6060:6060 &
curl -s "http://localhost:6060/debug/pprof/goroutineleak?debug=1" > quiet-night.txt
# ...wait for a busy period...
curl -s "http://localhost:6060/debug/pprof/goroutineleak?debug=1" > busy-period.txt
diff quiet-night.txt busy-period.txt

A leaked-goroutine count that only ever goes up, never down, even after traffic drops, is your signal. Cross-reference that timeline against kubectl describe pod and the OOMKilled reason under Last State, and you've gone from "memory keeps climbing, no idea why" to a stack trace pointing at the exact line that's holding onto memory. If you want to work backward from the crash instead, PodTriage walks through the exact kubectl commands to confirm an OOMKilled pod is a leak rather than an undersized limit before you ever open a profiler. If this service is part of a larger microservices setup, pulling that timeline alongside your existing distributed tracing and observability tooling makes it much faster to confirm the leak lines up with a specific upstream call pattern rather than general load.

What It Still Can't Catch

This isn't a silver bullet, and it's worth being precise about why. If a blocked goroutine is still reachable through a global variable, a package-level cache, or another goroutine that's technically still runnable (even if that other goroutine is itself never going to do anything useful), the reachability analysis won't flag it as leaked, because from the garbage collector's strict point of view, something could theoretically still reach in and unblock it.

It also only reports goroutines blocked on channels, mutexes, and condition variables. A goroutine stuck in an infinite for loop that's still actively running (burning CPU instead of being blocked) isn't a candidate for this profile at all; that's a different problem you'd catch with a CPU profile instead. goleak in your test suite and this profile in production complement each other rather than one replacing the other.

A Workflow You Can Actually Run This Week

Pick one service that's been mysteriously eating memory. Build it with the flag on, deploy it to staging behind the internal-only Service shown above, and add a scheduled job (a CronJob, or even a five-minute cron on your own machine hitting the port-forward) that curls the goroutineleak endpoint and writes the output somewhere you can diff over time. Run that for a week before you touch production.

The Go team has indicated Go 1.27 is expected to make this default-on, with no build flag required. Getting familiar with reading the output now, on a service you already suspect has a problem, costs you nothing later and means you'll already know what a real leak looks like the first time it shows up unprompted in a routine profile pull.

What's Next

Go 1.26 also shipped testing/synctest improvements for deterministic concurrency testing, which pairs well with this: synctest lets you fast-forward fake time and control goroutine scheduling inside a test, so you can write a test that deliberately triggers the exact blocking condition the leak profiler is designed to catch, rather than waiting for it to happen under real production load. If you're moving containers off Docker Compose onto Podman Quadlet and systemd as part of a broader infrastructure cleanup, that's also a good moment to standardize on exposing pprof endpoints consistently across every service, rather than adding them one at a time only after something breaks.

ToolCatches leaks inWhen you'd use it
goleakTest runs onlyCI, unit and integration tests, as a first line of defense
/debug/pprof/goroutineNothing specific, just a snapshotManual inspection when you already suspect a specific goroutine
/debug/pprof/goroutineleak (Go 1.26+)Running production or staging servicesCorrelating rising memory or OOMKilled pods with a provable root cause
testing/synctestDeliberately constructed test scenariosWriting a regression test once you've found a leak, so it can't come back

Frequently Asked Questions

Is GOEXPERIMENT=goroutineleakprofile safe to run in production?

It's still an experimental feature, so treat it the way you'd treat any diagnostic flag: test it on staging first, and roll it out to a subset of production pods before going wide. The profiling itself only runs when you hit the /debug/pprof/goroutineleak endpoint, so it doesn't add background overhead the way a continuously-sampling profiler would. The bigger operational risk is accidentally exposing /debug/pprof/* publicly, which is a pre-existing pprof concern, not something specific to this new profile.

Does the goroutineleak profile replace Uber's goleak library?

No, and you shouldn't remove goleak from your tests. goleak runs inside your test suite and catches leaks the moment a specific test triggers them, which is the fastest possible feedback loop. The goroutineleak profile is for services already running somewhere you can't easily reproduce in a unit test: production traffic, staging load tests, or long-running background workers. Use both. They catch the same class of bug at different points in the pipeline.

When does goroutine leak detection become default in Go, no flag required?

The Go team has signaled Go 1.27 is the expected target for making this default-on without the GOEXPERIMENT flag, following Go's usual pattern of shipping a feature experimentally for one release cycle before graduating it. That timeline can shift, so check the Go 1.27 release notes when they land rather than assuming the exact version.

What does the (leaked) tag in the stack trace actually mean?

It means the Go runtime's garbage collector has proven, through reachability analysis, that this specific goroutine is blocked on a channel, mutex, or condition variable that nothing in the program can ever access again to unblock it. It's not a heuristic guess based on how long the goroutine has been blocked; it's the same category of certainty the garbage collector uses to decide an object is safe to collect. That's different from an ordinary goroutine profile entry showing [chan send], which just means "blocked right now" and could easily be a perfectly healthy goroutine waiting a few milliseconds for its turn.

Can I enable goroutineleakprofile without rebuilding my service's binary?

No. GOEXPERIMENT flags are compile-time settings baked into the binary at build time, not runtime toggles you can flip with an environment variable on an already-built container image. You'll need to rebuild your image with GOEXPERIMENT=goroutineleakprofile set during the go build step, then redeploy. This is one more reason to standardize the flag into your CI build pipeline for services you're actively investigating, rather than trying to add it ad hoc to a running pod.

Does the goroutineleak profile work the same way inside a Docker or Kubernetes container?

Yes. It's a Go runtime feature baked into the binary at build time, so it behaves identically whether the process is running bare-metal, in a Docker container, or in a Kubernetes pod. The only container-specific consideration is making sure the pprof HTTP endpoint is reachable: bind it to an address the cluster can reach (not just localhost inside the container, unless you're using kubectl port-forward or exec to reach it directly), and put it behind an internal-only Service as shown above rather than exposing it through your public ingress.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

devops

40 Kubernetes Interview Questions and Answers (2026)

40 Kubernetes interview questions covering Pods, Deployments, networking, RBAC, and real troubleshooting scenarios like CrashLoopBackOff and OOMKilled. Updated for 2026.

Jun 26, 2026·38 min read
devops

Docker Compose to Podman Quadlet: A Practical Migration Guide

Convert a docker-compose.yml to Podman Quadlet files step by step, with a full example, rootless setup, and the daemon-reload gotchas that trip people up.

Jul 15, 2026·10 min read

On this page

  • The Leak That Doesn't Panic
  • The Old Ways, and Why They Fall Short
  • What Actually Shipped in Go 1.26
  • A Working Leak, Start to Finish
  • Reading the Profile Output
  • The Kubernetes Payoff
  • What It Still Can't Catch
  • A Workflow You Can Actually Run This Week
  • What's Next
  • Frequently Asked Questions
Advertisement