Search and navigate

Pages

Start HereRoadmapTutorialsCheatsheetInterview QuestionsResources

Published tutorials

Building a REST API with Go: The Complete TutorialGeneric Functions in Go: Writing Reusable CodeGo Benchmarking: Measuring Performance with testing.BGo Channels: Communication Between GoroutinesGo Constraints: comparable, Ordered and Custom SetsGo Context: Cancellation, Deadlines, and Request ValuesGo HTTP Client: Timeouts, JSON, and Reliable RequestsGo HTTP Middleware: Build a Production ChainGo Interview Questions: 25 Answers That Show UnderstandingGo Mutex: Protecting Shared State from Race ConditionsGo net/http: Build an HTTP Server from ScratchGo select Statement: Multiplexing Channels ExplainedGo Tools You Need for a Reliable Development LoopGo Tutorial: From First Program to Backend FoundationsGo Type Parameters: Syntax and Constraints ExplainedGo Worker Pool Pattern: Bounded Concurrency at ScaleGo's Type System Deep Dive: Static Typing Done RightGolang Compilation and Execution ExplainedGolang File Structure for Modules and ApplicationsGolang Hello World and Your First Go ProgramGolang IDEs: Choose an Editor for Your WorkflowGolang Interfaces: What They Are and How to Use ThemGoroutine Leaks in Go: How to Detect and Prevent ThemGoroutines in Go: Concurrency Without LeaksHow to Download and Install Golang SafelyHow to Learn Golang with a Focused Study PlanJSON in Go: Encoding, Decoding, and ValidationLearn Golang with a Practical Beginner Coursesync.WaitGroup in Go: Coordinating Multiple GoroutinesTable-Driven Tests in Go: The Idiomatic WayTesting in Go: Table Tests, HTTP, Fakes, and CoverageWhat Is Go and How Does a Go Program Work?Why Learn Golang and What Is Go Used For?Writing Unit Tests in Go: A Practical Guide
Open full search and filters →

Goroutine Leaks in Go: How to Detect and Prevent Them

Detect and fix golang goroutine leaks with tested code: runtime.NumGoroutine, the pprof goroutine profile, go test leak checks, and per-cause fixes.

Standard-library lessonGo requirement: Go 1.21+How tutorials are checked
Concurrency · Lesson 8Saved in this browser. No account required.
Concurrent execution paths with one stranded task highlighted and cancellation restoring a safe lifecycle
A goroutine becomes a leak when its execution path can no longer reach a valid exit. Image: Golang Tutorial

Reviewer’s note: A goroutine leak is a goroutine that never returns, so its stack and everything it references stay in memory for the life of the process. This guide shows how to know you have one, how to find it with runtime.NumGoroutine and the pprof goroutine profile, and how to fix every common cause. Every example was run on Go 1.24.7.

Works with Go 1.21+ (all output verified on Go 1.24.7). You should be comfortable with goroutines and channels first. If either is shaky, read the goroutines tutorial and the channels guide, because every leak in this article comes down to a goroutine blocked on a channel operation that will never complete.

How to know you have a goroutine leak

Goroutines are cheap, about 2 KB of stack each, so a leak rarely announces itself. It shows up as a slow climb in memory and, more reliably, a climb in the live goroutine count. The Go runtime exposes that count directly with runtime.NumGoroutine. If the number grows under steady load and never comes back down, goroutines are being created faster than they exit.

Here is a leak you can watch happen. fetchQuote starts a goroutine that produces one value on an unbuffered channel. The caller uses a timeout and sometimes walks away before the value arrives:

package main

import (
	"fmt"
	"runtime"
	"time"
)

// fetchQuote starts a goroutine that produces one value and sends it on an
// unbuffered channel. If the caller stops listening, that send blocks forever.
func fetchQuote(symbol string) <-chan string {
	out := make(chan string)
	go func() {
		time.Sleep(10 * time.Millisecond) // simulate a slow upstream call
		out <- symbol + " 142.11"          // blocks until someone receives
	}()
	return out
}

func main() {
	fmt.Println("goroutines at start:", runtime.NumGoroutine())

	for i := 1; i <= 5; i++ {
		quote := fetchQuote("GOOG")
		select {
		case <-quote:
			// got the value
		case <-time.After(2 * time.Millisecond):
			// caller gives up early; the sender is now stranded
		}
		time.Sleep(20 * time.Millisecond)
		fmt.Printf("after %d requests: %d goroutines\n", i, runtime.NumGoroutine())
	}
}

Output:

goroutines at start: 1
after 1 requests: 2 goroutines
after 2 requests: 3 goroutines
after 3 requests: 4 goroutines
after 4 requests: 5 goroutines
after 5 requests: 6 goroutines

One extra goroutine sticks around per request. In a real service handling thousands of requests a minute, that line goes up forever. The first diagnostic move is always the same: log runtime.NumGoroutine() on an interval and see whether it trends up.

Why goroutine leaks happen

Every leak is a goroutine parked on an operation that can never proceed. There are four shapes worth memorizing, and each one is provable with a before-and-after goroutine count.

A send with no receiver

An unbuffered send blocks until someone receives. If the receiver is gone, the sender waits forever. This is the pattern behind the quote example above, isolated:

func leakOnSend() {
	results := make(chan int) // unbuffered
	go func() {
		results <- 42 // no one ever receives: this goroutine blocks forever
	}()
	// The function returns. The channel goes out of scope, but the goroutine
	// parked on the send keeps it (and itself) alive.
}

Wrapping that in a main that prints runtime.NumGoroutine() before and after (with a runtime.GC() in between to prove garbage collection does not save you) gives:

before: 1
after:  2

Garbage collection cannot reap a running goroutine. A goroutine is a root; anything it references is reachable by definition. This is the leak Ardan Labs calls “the forgotten sender,” and it is the most common one in production.

A receive with no sender

The mirror image. A goroutine waits to receive a value that nobody will send:

func leakOnReceive() {
	work := make(chan int)
	go func() {
		v := <-work // waits for a value that never comes
		fmt.Println("processed", v)
	}()
	// Nobody ever sends on work, so the goroutine parks on the receive forever.
}

Same result: the count goes from 1 to 2 and stays there.

A range over a channel that is never closed

for range ch ends only when ch is closed. Forget the close and the consumer loops forever:

func leakOnRange() {
	jobs := make(chan int)
	go func() {
		for range jobs { // loops until jobs is closed... which never happens
		}
		fmt.Println("consumer done") // unreachable
	}()
	for i := 0; i < 3; i++ {
		jobs <- i
	}
	// We stop sending but never close(jobs), so the range never ends.
}

The consumer processes all three jobs, then blocks on the next receive because the range is still waiting for more or for a close. Before 1, after 2.

A missing cancellation signal

A background loop with no exit path leaks once per start. This poller runs a ticker with no way to stop it:

func startPoller() {
	go func() {
		tick := time.NewTicker(5 * time.Millisecond)
		defer tick.Stop()
		for range tick.C {
			_ = time.Now() // poll something; there is no exit path
		}
	}()
}

Start it four times, one per request say, and the count goes to 5 and stays. The defer tick.Stop() never runs because the loop never returns.

Detecting leaks: the tooling

NumGoroutine tells you a leak exists. These tools tell you where it is.

runtime.NumGoroutine as the cheap signal

Keep it as a permanent health signal, not just a debugging trick. Export it as a metric, or log it every 30 seconds. A flat line means your goroutine lifecycle is balanced. A staircase means a leak. It costs almost nothing to read, so there is no reason not to watch it in production.

The pprof goroutine profile

NumGoroutine gives you a number; the goroutine profile gives you the stack traces those goroutines are stuck on, grouped and counted. That grouping is the whole game: a leak shows up as a large count of identical stacks all parked on the same line.

You can dump the profile in-process without any HTTP server:

package main

import (
	"os"
	"runtime/pprof"
	"time"
)

func waitForNothing(work chan int) {
	<-work // parks forever; nobody sends
}

func main() {
	work := make(chan int)
	for i := 0; i < 3; i++ {
		go waitForNothing(work)
	}
	time.Sleep(10 * time.Millisecond) // let them all park

	// Dump the goroutine profile with debug=1: counts plus one stack per group.
	pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
}

Output (trimmed):

goroutine profile: total 4
3 @ 0x46bf0e 0x40c305 0x40beb2 0x4cf565 0x472a01
#	0x4cf564	main.waitForNothing+0x24	/tmp/gl/p8_pprof.go:10

Read the line 3 @ ...: three goroutines share one stack, all parked inside main.waitForNothing at line 10. That is the leak, named and counted. When you have a real leak, the group with the surprising count and a stack pointing into your own code is the culprit.

In a running service you expose the same data over HTTP by importing net/http/pprof for its side effect, which registers handlers on the default mux:

import _ "net/http/pprof"

Then hit http://localhost:6060/debug/pprof/goroutine?debug=1 and you get the identical grouped dump. Wired up and queried in-process, the endpoint returns exactly the stack we saw:

goroutine profile: total 11
#	0x699364	main.waitForNothing+0x24	/tmp/gl/p9_httppprof.go:14

For a live process the practical move is to capture the profile twice, a minute apart, and diff the counts. The stack whose count grew is your leak. Use go tool pprof http://localhost:6060/debug/pprof/goroutine to explore it interactively.

A go test leak check

You can catch leaks in tests by comparing the goroutine count before and after, with a short retry loop because goroutines exit asynchronously:

func assertNoLeak(t *testing.T, baseline int) {
	t.Helper()
	for i := 0; i < 50; i++ {
		if runtime.NumGoroutine() <= baseline {
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("goroutine leak: baseline %d, now %d", baseline, runtime.NumGoroutine())
}

func TestStartWorkerLeaks(t *testing.T) {
	baseline := runtime.NumGoroutine()
	startWorker() // starts a goroutine that blocks on a receive forever
	assertNoLeak(t, baseline)
}

Against a leaky startWorker, the test fails as it should:

--- FAIL: TestStartWorkerLeaks (0.51s)
    leak_test.go:31: goroutine leak: baseline 2, now 3
FAIL

This hand-rolled check works, but for anything beyond a single test reach for uber-go/goleak (v1.3.0, import path go.uber.org/goleak). It snapshots goroutines, filters out the runtime’s own, and fails the test with the offending stack. Add one TestMain and every test in the package is covered:

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

Or guard a single test with defer goleak.VerifyNone(t). When a goroutine outlives the test, goleak prints its full stack, so you get the same “which line is it parked on” answer as pprof, automatically, in CI. (goleak is a third-party module and was not compiled in this article’s sandbox; the usage above is verified against its v1.3.0 documentation.)

What the race detector does and does not do

A frequent misconception: go test -race does not detect goroutine leaks. The race detector finds concurrent unsynchronized access to shared memory, not goroutines that fail to exit. It is still worth running, because the sloppy lifecycle management that causes leaks often ships alongside data races, but a clean -race run tells you nothing about leaks. Keep the two tools separate in your head.

Fixing each cause

Every fix is the same principle applied differently: give the blocked goroutine a way out.

Close channels from the sender, or buffer the send

For the forgotten sender, the rule is that the sender closes the channel, never the receiver, and only the sender knows when it is done. For a range consumer, closing the channel is what ends the loop. Read the channels guide for the full ownership rules.

When a goroutine produces exactly one value and the caller might abandon it, a buffered channel of capacity 1 is the clean fix. The send always succeeds, so the goroutine finishes instead of parking:

func fetchQuote(symbol string) <-chan string {
	out := make(chan string, 1) // room for exactly one value
	go func() {
		time.Sleep(10 * time.Millisecond)
		out <- symbol + " 142.11" // never blocks
	}()
	return out
}

Rerunning the opening program with this one change flattens the count completely:

goroutines at start: 1
after 1 requests: 1 goroutines
after 2 requests: 1 goroutines
after 3 requests: 1 goroutines
after 4 requests: 1 goroutines
after 5 requests: 1 goroutines

Select on ctx.Done for long-lived goroutines

For a loop that should stop when work is cancelled, pass a context and add a ctx.Done() case to a select. The poller from earlier, fixed:

func startPoller(ctx context.Context) {
	go func() {
		tick := time.NewTicker(5 * time.Millisecond)
		defer tick.Stop()
		for {
			select {
			case <-ctx.Done():
				return // the defined exit condition
			case <-tick.C:
				_ = time.Now()
			}
		}
	}()
}

Start four pollers with a shared cancellable context, then call cancel():

before: 1
running: 5
after:   1

All four goroutines return the moment the context is cancelled, and defer tick.Stop() finally runs. This is the single most useful pattern in the language for lifecycle control: any goroutine that outlives a single function call should take a context and select on its Done channel.

Bound the work with a worker pool

Spawning one goroutine per item works until the items arrive faster than they finish, at which point goroutines pile up even without a hard leak. A fixed set of workers reading from a channel caps concurrency and gives every worker a clear exit (the channel closing). The worker pool guide builds this pattern end to end; the leak-relevant point is that a bounded pool turns “unbounded goroutine growth” into “bounded queue growth,” which is far easier to reason about and monitor.

Prevention: define the exit before you write go

There is one rule that prevents every leak in this article. Before you type go, answer: what makes this goroutine return? If you cannot name the condition, you have a leak in waiting. In practice the answer is one of:

  • The channel it reads gets closed by its owner.
  • Its context is cancelled and it selects on ctx.Done().
  • It does a bounded amount of work and returns.

If none of those apply, do not start the goroutine. This question, asked every time, is worth more than any detector. The concurrency pillar covers the ownership habits that make the answer obvious; the main Go tutorial is the place to start if goroutines themselves are still new.

A realistic leaking service

Here is the shape the leak takes in real code. A search handler calls a slow backend through a per-request goroutine and gives up after 10ms. The backend goroutine takes 30ms, so on every timeout it is stranded on the send:

func searchBackend(query string) <-chan string {
	out := make(chan string) // unbuffered
	go func() {
		time.Sleep(30 * time.Millisecond) // slower than our timeout
		out <- "results for " + query     // strands here when the handler bailed
	}()
	return out
}

func searchHandler(w http.ResponseWriter, r *http.Request) {
	results := searchBackend(r.URL.Query().Get("q"))
	select {
	case res := <-results:
		fmt.Fprintln(w, res)
	case <-time.After(10 * time.Millisecond):
		http.Error(w, "backend timeout", http.StatusGatewayTimeout)
	}
}

Driving five requests through an httptest server and counting goroutines shows the staircase:

before any request: 2
after 5 requests:   7

Five timeouts, five stranded backend goroutines. The fix combines two of the patterns above: derive a context from the request, cancel it with defer, and give the backend both a ctx.Done() exit and a buffered channel:

func searchBackend(ctx context.Context, query string) <-chan string {
	out := make(chan string, 1) // buffered: the send never blocks
	go func() {
		select {
		case <-time.After(30 * time.Millisecond):
			out <- "results for " + query
		case <-ctx.Done():
			return // request ended; stop working
		}
	}()
	return out
}

func searchHandler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Millisecond)
	defer cancel() // cancelling frees the backend goroutine

	results := searchBackend(ctx, r.URL.Query().Get("q"))
	select {
	case res := <-results:
		fmt.Fprintln(w, res)
	case <-ctx.Done():
		http.Error(w, "backend timeout", http.StatusGatewayTimeout)
	}
}

Same five requests, flat count:

before any request: 2
after 5 requests:   2

r.Context() is cancelled automatically when the client disconnects or the handler returns, so wiring your goroutines to it means they get cleaned up for free.

Common mistakes

Waiting on a channel nobody closes. A for range ch or a bare <-ch with no corresponding close or send is a permanent park. Decide who owns the channel and make that owner responsible for closing it exactly once.

Forgetting to cancel the context. context.WithCancel and WithTimeout return a cancel function that you must call, always with defer cancel(). Skip it and both the context’s own goroutine (for WithTimeout) and anything selecting on ctx.Done() can leak. go vet will warn you about the unused cancel; do not ignore it.

Fire-and-forget goroutines. go doSomething() with no context, no channel handshake, and no bounded work is a leak whenever doSomething can block. Every goroutine needs an owner who knows when it ends.

Leaking timers with time.After in a loop. time.After allocates a new timer each call. In a hot for-select loop where another case usually fires first, older Go versions kept every unfired timer alive until it expired, which for a long timeout was effectively a leak. Go 1.23 changed this: an unreferenced Timer or Ticker is now collected immediately, even without Stop, so the classic time.After-in-a-loop leak is gone if your go.mod targets 1.23 or later. It is still cleaner to create one time.NewTimer outside the loop and call Reset on it, and it is mandatory if you must support older Go. Do not repeat the old advice without checking your target version.

What next

  • Go context tutorial: cancellation, deadlines, and the Done channel that ends most leaks.
  • Go select statement: the multiplexing primitive that lets a goroutine watch for cancellation while it works.
  • Go worker pools: bound concurrency so goroutine growth can never run away.
  • Channels in Go: channel ownership and the close rules that prevent forgotten-sender leaks.