Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Go  Module 15 – Concurrency patterns in Go

Goroutine leaks and the goroutineleak profile

Find leaked goroutines with the goroutineleak profile of Go 1.27 and with goleak and synctest in tests, see what the profile misses, and fix each kind of leak.

  • Advanced
  • 15 minutes
  • Examples run with Go 1.27.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 15 minutes free
Free: A leaked goroutine waits for ever on something nobody will touch · The goroutineleak profile reports goroutines that can never wake. With a pass: 2 in-depth parts and 3 interview answers, about 9 minutes.

What you will learn

  • Recognise the common causes of goroutine leaks: early returns, channels nobody closes and loops nobody stops
  • Detect leaks with the goroutineleak profile of Go 1.27, and say which leaks it cannot see
  • Catch leaks in tests with goleak and synctest
  • Fix leaks with buffered channels, contexts and one owner who closes

Before you start

What you’ll learn · Before you start

What you will learn

  • Recognise the common causes of goroutine leaks: early returns, channels nobody closes and loops nobody stops
  • Detect leaks with the goroutineleak profile of Go 1.27, and say which leaks it cannot see
  • Catch leaks in tests with goleak and synctest
  • Fix leaks with buffered channels, contexts and one owner who closes

Before you start

On this page

A function that checks five items at once and returns at the first error can leave the goroutines of the later checks waiting for ever: in leak.go below, one call left 3 goroutines behind, and the runtime said nothing about them. At 2,000 failing calls an hour, that is 6,000 goroutines an hour that never end, each with its stack and whatever it refers to.

Goroutine leaks like this one are invisible until memory runs out. You will see how they happen, find them with the goroutineleak profile: A profile of runtime/pprof, generally available since Go 1.27, that reports goroutines blocked on a channel or lock that no goroutine able to run can reach, so that nothing can ever wake them. that Go 1.27 made generally available, and name the fix for each kind. The output shown was recorded with Go 1.27.1.

A leaked goroutine waits for ever on something nobody will touch

A goroutine leak: A goroutine that stays blocked for ever, such as on a send that nobody will receive, so that its stack and everything it refers to stay in memory. is a goroutine that will never run again and never return. The Go 1.26 release notes show a common shape of it: goroutines send their results on an unbuffered channel, and the receiver returns early, at the first error. leak.go does the same, with checks that take 20 ms per item and an error at item 2, once with a channel buffered for all five results and once unbuffered:

leak.go: the same early return, buffered and unbuffered Go · leak.go
// leak.go: checkOrder checks the five items of an order at the same time and returns at the first error, the shape of
// the example in the Go 1.26 release notes. Run with a result channel buffered for every item, nothing is left behind.
// Run with an unbuffered one, the goroutines whose checks finish after the error wait for ever to send a result that
// nobody will receive, and Go 1.27's goroutineleak profile reports them.
package main

import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"path/filepath"
	"runtime"
	"runtime/pprof"
	"strings"
	"time"
)

// check stands for one check of an item: item i takes i × 20 ms, and item 2 fails.
func check(item int) error {
	time.Sleep(time.Duration(item) * 20 * time.Millisecond)
	if item == 2 {
		return errors.New("item 2: out of stock")
	}
	return nil
}

func checkOrder(items []int, buffer int) error {
	results := make(chan error, buffer)
	for _, item := range items {
		go func() { results <- check(item) }()
	}
	for range items {
		if err := <-results; err != nil {
			return err // the early return: nobody receives the remaining results
		}
	}
	return nil
}

// leakReport prints the goroutineleak profile's total, and where its leaked goroutines of package main wait.
func leakReport() {
	var out bytes.Buffer
	pprof.Lookup("goroutineleak").WriteTo(&out, 1)
	sc := bufio.NewScanner(&out)
	count := ""
	for sc.Scan() {
		line := sc.Text()
		switch {
		case strings.HasPrefix(line, "goroutineleak profile:"):
			fmt.Println(" ", line)
		case strings.Contains(line, " @ "):
			count = strings.Fields(line)[0] // "3 @ 0x… 0x…": how many goroutines share the stack below
		case strings.HasPrefix(line, "#") && strings.Contains(line, "main.") && count != "":
			f := strings.Fields(line) // "#  0x…  main.checkOrder.func1+0x77  /…/leak.go:31"
			fn := f[2][:strings.LastIndex(f[2], "+")]
			fmt.Printf("  %s waiting in %s at %s\n", count, fn, filepath.Base(f[3]))
			count = ""
		}
	}
}

// run checks one order, waits until every check has finished, and reports what is left.
func run(name string, buffer int) {
	before := runtime.NumGoroutine()
	fmt.Printf("%s: %v\n", name, checkOrder([]int{1, 2, 3, 4, 5}, buffer))
	time.Sleep(150 * time.Millisecond)
	fmt.Println("  goroutines left:", runtime.NumGoroutine()-before)
	leakReport()
}

func main() {
	run("buffered for 5 results", 5)
	run("unbuffered", 0)
}

Output

buffered for 5 results: item 2: out of stock
  goroutines left: 0
  goroutineleak profile: total 0
unbuffered: item 2: out of stock
  goroutines left: 3
  goroutineleak profile: total 3
  3 waiting in main.checkOrder.func1 at leak.go:31

Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run leak.go

With the buffer, each check could send its result and return, so nothing was left. Unbuffered, the checks of items 3, 4 and 5 finished after checkOrder had returned and waited to send to a receiver that no longer existed: 3 goroutines left, counted by runtime.NumGoroutine. The runtime’s deadlock check never fires for them, because it needs every goroutine of the program to be blocked, and main kept running.

Most goroutine leaks are one of three shapes: a send or a receive on a channel that its other side has abandoned, a channel that its owner never closes, and a loop, often on a ticker, that nothing tells to stop.

Checkpoint

Check: What a goroutine leak is

  1. Question 1 of 2 checkOrder checks 7 items at once, item i taking i × 20 ms, sends each result on an unbuffered channel and returns at the first error, which is item 3's. How many goroutines leak?

    Type a number.

    A hint

    Which checks finish after the error has been received?

    Show the answer: Question 1 of 2

    Answer: 4 goroutines

    Items 1 and 2 report before the error and are received. Item 3's error makes checkOrder return, so items 4, 5, 6 and 7 finish later and wait for ever to send: 4 goroutines, as items 3 to 5 were 3 in leak.go.

  2. Question 2 of 2 Three goroutines of leak.go wait for ever in a send. Why did the runtime not stop the program with a deadlock?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Its deadlock check needs every goroutine to be blocked, and main kept running

    A leak in a running program is silent: the runtime has no reason to stop a program that is still doing work, so the leaked goroutines simply stay.

The goroutineleak profile reports goroutines that can never wake

Go 1.27 made the goroutineleak profile generally available. It is a profile of runtime/pprof, read with pprof.Lookup("goroutineleak"), and a program that serves net/http/pprof offers it at /debug/pprof/goroutineleak. The release notes explain how it decides: the runtime runs a garbage collection that marks only from goroutines that can run, and a goroutine blocked on a channel or lock that none of them can reach can never wake. leak.go’s last lines came from that profile: total 3, all waiting in checkOrder.func1 at the send of line 31. Step through how it found them:

Interactive · step by step How the leak profile finds the three senders of leak.go Step through the unbuffered run of leak.go, from the early return to the profile's report.
How the leak profile finds the three senders of leak.go, step 7 of 7. Showing main, checkOrder (returned at 40 ms), results (buffered for 5), check 3 (blocked: chan send), check 4 (blocked: chan send), check 5 (blocked: chan send). In focus: results.mainrunnablecheckOrderreturned at 40 msresultsbuffered for 5check 3blocked: chan sendcheck 4blocked: chan sendcheck 5blocked: chan send

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Five checks start. checkOrder starts one goroutine per item. Each will send its result on results, a channel with no buffer, so each send waits until checkOrder receives.
  2. The early return. Item 1 reports at 20 ms and item 2's error at 40 ms. checkOrder returns that error at once, and nobody receives from results any more.
  3. Three senders wait. Items 3, 4 and 5 finish at 60, 80 and 100 ms and try to send. No receiver will ever come, so each waits in its send for good.
  4. Nothing else reaches results. Once checkOrder has returned, the only references to results are the three senders' own. main runs on and refers to it nowhere.
  5. The leak profile looks. Asked for the goroutineleak profile, the runtime runs a garbage collection that marks only from goroutines that can run, here main. It never reaches results.
  6. Three leaks reported. A goroutine blocked on something no runnable goroutine can reach can never wake, so all three are leaked. The profile says total 3, at the send line.
  7. The buffered run. With room in results for all 5 errors, every send completes at once and its goroutine returns. There is nothing to report, as the first run showed.

Because it works by reachability, the release notes add, the profile can miss leaks whose channel or lock a package-level variable or a running goroutine can still reach. Four tools between them see what any one misses:

Detector Where it runs What it reports
the goroutineleak profile a running program, production too goroutines blocked on something unreachable
runtime.NumGoroutine anywhere, as a metric how many goroutines exist, leaked or not
goleak.VerifyNone a test goroutines still running when the test ends
synctest.Test a test goroutines of its bubble still blocked when it returns

Each kind of leak has its fix: a buffer with room for every sender’s result, a select with ctx.Done() beside every send and receive, and one owner who closes each channel.

Key idea

The goroutineleak profile reports a goroutine when nothing that can still run could ever wake it, which catches early returns and unclosed channels, and misses what a global or a running goroutine can still reach.

Checkpoint

Check: The goroutineleak profile and the fixes

  1. Question 1 of 2 A goroutine waits for ever to send on a channel that is also a field of a struct, and a goroutine of the server still holds that struct and keeps running. Does the goroutineleak profile report the waiting goroutine?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No, because a goroutine that can still run can reach the channel

    The release notes name this limit: the profile may miss leaks whose primitive a global or a runnable goroutine can still reach. runtime.NumGoroutine still counts such a goroutine.

  2. Question 2 of 2 A goroutine ranges over a channel, and the function that sends on it returns without closing it. Which fix ends the goroutine?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The sending function, the channel's owner, closes it after its last send

    Match the fix to the shape: a buffer for senders that nobody reads, a select with ctx.Done() for waits that a cancel should end, and a close by the one owner for receivers ranging over a channel.

Four kinds of leak, reproduced, and the two the profile misses Pro: opens with a Pro, Premium or Ultimate pass

Four goroutines that never end, one of each common kind, show where each detector helps. This part runs them and their fixes, compares what runtime.NumGoroutine counts with what the goroutineleak profile reports, and explains the two leaks the profile cannot see.

In this part:

  • Four leaks of four kinds, and what each detector saw
  • Why the ticker loop and the global channel escape the profile
  • Each fix, and why it is the right one
  • What a leak costs, worked out

1 runnable example (Go) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Leak checks in tests and in production Pro: opens with a Pro, Premium or Ultimate pass

A leak found by a test costs a minute; one found by an out-of-memory alert costs a night. This part runs the two test checks on leak.go’s function, reproduces the trap that makes one leaky test fail its neighbours, and serves the profile safely in a running service.

In this part:

  • goleak fails the test that leaks, and the next test too
  • synctest wants every goroutine of its bubble finished
  • The profile in production, on an internal port

3 runnable examples (Go, Go (order.go), Go (order_test.go), MOD, Python) · about 5 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Practise: three functions that leak

practice/leaks.go holds three functions that each leave goroutines behind in their own way. Fix each so that it leaves none, without changing what it returns. practice/leaks_test.go asks the goroutineleak profile, counting only each test’s own function, since a leak lives on for the whole test binary:

practice/leaks.go and practice/leaks_test.go

Go (leaks.go) · practice/leaks.go

// Package practice is the practice of the lesson on goroutine leaks: three functions that each leave goroutines
// behind in their own way. Fix each one so that it leaves none, without changing what it returns.
package practice

import "time"

// FirstError runs check for every item at the same time and returns the first error, or nil.
func FirstError(items []int, check func(item int) error) error {
	errs := make(chan error)
	for _, item := range items {
		go func() { errs <- check(item) }()
	}
	for range items {
		if err := <-errs; err != nil {
			return err
		}
	}
	return nil
}

// WithTimeout runs work and returns its result, or "", false if work takes longer than d.
func WithTimeout(work func() string, d time.Duration) (string, bool) {
	result := make(chan string)
	go func() { result <- work() }()
	select {
	case r := <-result:
		return r, true
	case <-time.After(d):
		return "", false
	}
}

// PlaceOrders hands every order to a notifier goroutine, which calls notify for each, and returns how many it placed.
func PlaceOrders(ids []string, notify func(id string)) int {
	orders := make(chan string)
	go func() {
		for id := range orders {
			notify(id)
		}
	}()
	for _, id := range ids {
		orders <- id
	}
	return len(ids)
}

Go (leaks_test.go) · practice/leaks_test.go

package practice

import (
	"bufio"
	"bytes"
	"errors"
	"runtime/pprof"
	"strconv"
	"strings"
	"testing"
	"time"
)

// leaked counts the goroutines that the goroutineleak profile reports with fn in their stack. Leaks of one test stay
// alive for the whole test binary, so each test counts only those of its own function.
func leaked(t *testing.T, fn string) int {
	t.Helper()
	time.Sleep(100 * time.Millisecond) // let every goroutine the function started finish or block
	var out bytes.Buffer
	if err := pprof.Lookup("goroutineleak").WriteTo(&out, 1); err != nil {
		t.Fatal(err)
	}
	total, count := 0, 0
	sc := bufio.NewScanner(&out)
	for sc.Scan() {
		line := sc.Text()
		if n, _, ok := strings.Cut(line, " @ "); ok {
			count, _ = strconv.Atoi(n)
		} else if strings.HasPrefix(line, "#") && strings.Contains(line, "practice."+fn) {
			total += count
			count = 0 // count each stack once
		}
	}
	return total
}

func TestFirstError(t *testing.T) {
	broken := errors.New("item 2: out of stock")
	err := FirstError([]int{1, 2, 3, 4, 5}, func(item int) error {
		time.Sleep(time.Duration(item) * 5 * time.Millisecond)
		if item == 2 {
			return broken
		}
		return nil
	})
	if !errors.Is(err, broken) {
		t.Fatalf("FirstError returned %v; want item 2's error", err)
	}
	if n := leaked(t, "FirstError"); n != 0 {
		t.Fatalf("leaked goroutines of FirstError: %d; want 0", n)
	}
}

func TestWithTimeout(t *testing.T) {
	r, ok := WithTimeout(func() string {
		time.Sleep(50 * time.Millisecond)
		return "report"
	}, 10*time.Millisecond)
	if r != "" || ok {
		t.Fatalf("WithTimeout returned %q, %v; want \"\", false", r, ok)
	}
	if n := leaked(t, "WithTimeout"); n != 0 {
		t.Fatalf("leaked goroutines of WithTimeout: %d; want 0", n)
	}
}

func TestPlaceOrders(t *testing.T) {
	var notified []string
	n := PlaceOrders([]string{"A-1", "A-2", "A-3"}, func(id string) { notified = append(notified, id) })
	if n != 3 {
		t.Fatalf("PlaceOrders returned %d; want 3", n)
	}
	if n := leaked(t, "PlaceOrders"); n != 0 {
		t.Fatalf("leaked goroutines of PlaceOrders: %d; want 0", n)
	}
	if len(notified) != 3 {
		t.Fatalf("notified %v; want all 3 orders", notified)
	}
}

The starter code compiles, and every test fails:

The three leaky functions against their tests Python · practice_session.py
"""The three functions of practice/leaks.go against their tests, which ask the goroutineleak profile."""

import session

session.run(
    """
cd practice && go test .
""",
    expect_failure=["cd practice && go test ."],
)

Output

$ cd practice && go test .
--- FAIL: TestFirstError (…s)
    leaks_test.go:50: leaked goroutines of FirstError: 3; want 0
--- FAIL: TestWithTimeout (…s)
    leaks_test.go:63: leaked goroutines of WithTimeout: 1; want 0
--- FAIL: TestPlaceOrders (…s)
    leaks_test.go:74: leaked goroutines of PlaceOrders: 1; want 0
FAIL
FAIL	example.com/practice	…s
FAIL
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

Make go test . pass, then go test -race . as well. A first hint: FirstError and WithTimeout each need a channel with room for every sender, and PlaceOrders needs one owner who closes the channel and waits for the reader to finish.

The self-check below covers the two sections outside the locked parts.

Practice that adapts to you

Practise this lesson

7 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 7 What is a goroutine leak?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: A goroutine that will never run again and never return

    Goroutines are not garbage collected. One that waits for ever, or loops for ever with nobody to stop it, is a leak.

  2. Question 2 of 7 The timeout wins in this function. What is left behind?

    Read the code, then choose one answer.

    func fetch(d time.Duration) (string, bool) {
        result := make(chan string)
        go func() { result <- slowReport() }()
        select {
        case r := <-result:
            return r, true
        case <-time.After(d):
            return "", false
        }
    }
    Show the answer: Question 2 of 7

    Answer: One goroutine, waiting for ever to send the report

    A buffer of one place, make(chan string, 1), lets the late send complete and the goroutine return.

  3. Question 3 of 7 Which of these goroutines is not a leak?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: A worker that waits on its jobs channel, which the program's owner closes at shutdown

    Waiting is not leaking. A goroutine leaks when nothing will ever end its wait or its loop.

  4. Question 4 of 7 How does the goroutineleak profile decide that a goroutine has leaked?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: A garbage collection marks from runnable goroutines, and a goroutine blocked on something they cannot reach can never wake

    Reachability is what makes the profile precise, and also what makes it miss leaks that a global or a running goroutine still reaches.

  5. Question 5 of 7 A running service imports net/http/pprof and serves it. Where do you read its leaked goroutines?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: At /debug/pprof/goroutineleak, which also takes debug=1 for text

    In code, pprof.Lookup("goroutineleak") gives the same profile, as leak.go read it.

  6. Question 6 of 7 runtime.NumGoroutine grows by about 100 an hour, while the goroutineleak profile reports total 0. What is the likeliest explanation?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: Goroutines that never end but are not blocked on something unreachable, such as waits on a channel that a global or a running goroutine still reaches

    Use both: the profile says where goroutines wait for ever, the count says that something is growing. A full goroutine profile then shows what the extra goroutines are doing.

  7. Question 7 of 7 A goroutine sends its result on an unbuffered channel, and its caller may give up and return first. Which change ends the goroutine whatever the caller does?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: Give the channel one place for the result

    A buffer of one place per sender, or a select that also waits on ctx.Done(), lets every send end. The buffer is the simplest when there is exactly one result.

Interview questions

The interview set for leaks: a warm-up with a full answer first, and three deeper questions from the module bank:

  1. Fresher to mid · Warm-up

    What is a goroutine leak, and why does the Go runtime not report it as a deadlock?

    A goroutine leak is a goroutine that will never run again and never return, such as one waiting for ever to send a result that its caller stopped waiting for. Goroutines are not garbage collected, so it keeps its stack and everything it refers to until the program ends. The runtime reports a deadlock only when every goroutine of the program is blocked; in a server, other goroutines keep running, so a leak stays silent. It shows up as a goroutine count and memory that grow over hours. Go 1.27's goroutineleak profile reports goroutines blocked on something no runnable goroutine can reach, and tests can catch leaks with goleak or synctest.

  2. Mid to senior

    A Go service's memory climbs steadily between deploys and drops after each restart, and runtime.NumGoroutine climbs the same way. How do you find the leak, and what do you look for in the code once you know where the goroutines wait?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Senior to staff

    runtime.NumGoroutine rises by thousands a day, but the goroutineleak profile reports total 0. Which kinds of leak can hide from the profile, and how do you find this one?

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    After goleak.VerifyNone was added to every test of a package, its tests fail in different places depending on which tests run, and some tests of correct code fail. Explain what happens, and set up leak checks whose failures point at the test that leaked.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Goroutine leaks are goroutines that never return: a send or receive whose other side left, a channel nobody closes, or a loop nobody stops.
  • Go 1.27’s goroutineleak profile reports goroutines blocked on something no runnable goroutine can reach, and can miss what a global or a running goroutine still reaches.
  • The fixes are a buffer for every sender, a select with ctx.Done(), and one owner who closes each channel.

Terms in this lesson

goroutineleak profile
A profile of runtime/pprof, generally available since Go 1.27, that reports goroutines blocked on a channel or lock that no goroutine able to run can reach, so that nothing can ever wake them.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.