Before you begin
Why this matters
A Go program whose memory keeps growing almost always has something long-lived that still points at values it has finished with: a map, a slice, a small slice of a big array, or a goroutine that never ends. Knowing what the runtime looks for lets you find that pointer.
What this helps you reason about
- Predict which values the program can still reach after a given line.
- Explain why a small slice can keep a large array in memory.
- Close files with defer instead of waiting for the garbage collector.
In Go you never free memory yourself, and the common picture, that values you are finished with are cleaned up for you, is correct. This lesson makes it exact: which values the Go runtime may reclaim, how a value you no longer use can stay in memory, and why you cannot rely on when it happens.
Already know this? The short version
- The Go collector reclaims a value some time after no path of pointers from a root reaches it: the variables of running calls in every goroutine, and package-level variables.
- It traces from the roots, so cycles are reclaimed; it does not count references, move heap values, or split the heap by age.
- Leaks are values still reachable through a long-lived map or slice, a goroutine blocked forever, or a small slice or substring that shares a large backing array; copy the small part with bytes.Clone, strings.Clone, or append onto nil.
- Reslicing to s[:0] keeps the old elements reachable through the backing array; clear the slots or set s = nil.
- Finalizers and cleanups (runtime.AddCleanup) may run late or never; close files with defer f.Close().
Words in this lesson (8)
- garbage collector
- The part of the Go runtime that finds values that are no longer reachable and reuses their memory, at a time it chooses.
- root
- A starting point that the garbage collector always treats as in use: a live variable of a call that is still running in any goroutine, or a package-level variable.
- reachable
- A value is reachable when the program can still get to it by following pointers from the variables of running calls or from package-level variables.
- reference counting
- A way to reclaim memory by counting the references to each value and freeing the value when its count reaches zero. Values that refer to each other keep each other’s counts above zero, so counting alone never frees a cycle.
- tracing collector
- A garbage collector that starts from the roots, marks every value it can reach, and reclaims the rest. Values that refer only to each other are reclaimed too.
- memory leak
- Memory that stays in use for values the program will never use again. With a garbage collector, it happens when something reachable still refers to those values.
- backing array
- The array that holds a slice’s elements. Several slices can use one backing array, each using its own part of it.
- finalizer
- A function that the Go runtime may run some time after a value becomes unreachable, set with runtime.SetFinalizer or runtime.AddCleanup. There is no promise that it runs before the program exits.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / The common picture
What happens to values you stop using?
You never free memory yourself. The garbage collector, a part of the runtime, reuses the memory of values you are finished with. But how does it know that you are finished with a value?
The collector cannot know what your program will do next. So it does not ask whether a value will be used again. It asks a question it can answer: can the program still reach the value? A value becomes eligible for reclamation, which means its memory may be reused, when no path of references from the running program leads to it.
Values are cleaned up when the program can no longer reach them, not when you stop using them.
Coming from C: Go has no free: setting a pointer to nil removes one path to a value, and the runtime reclaims the value only when no path from a root remains.
02 / Roots
Where does the collector start looking?
The collector starts from a few places that the program can always use. These starting points are called roots. In Go, the roots are the live variables of calls that are still running, in every goroutine, and package-level variables. A live variable is one the rest of its call may still use; section 06 explains why that matters.
From each root, the collector follows references: from a variable to a list, from the list to the objects in it, from an object to the objects its fields refer to. Everything it finds this way is reachable. Everything else can be reclaimed.
In the program below, setting draft to nil removes one path to the notice. board still leads to it, so the notice stays. Only when board is nil does the last path disappear.
board := []*Notice{}
draft := &Notice{Text: "fire drill at 3"}
board = append(board, draft)
draft = nil // board still leads to the notice
board = nil // now nothing does A value stays as long as any path from a root leads to it. One remaining path is enough.
Coming from Java: A goroutine blocked forever is like a thread that never ends: its variables stay roots. Java’s Thread.stop is deprecated; Go has no way at all to stop a goroutine from outside, so give it a way to finish, such as a closed channel or a cancelled context.
Experiment
Follow every path to a value
Clearing draft removes one path to the notice, not the notice itself. The list in board holds a second reference. Once board refers to a new, empty list, no root leads to the old list or to the notice.
Why it matters To decide whether a value can be reclaimed, look for any remaining path from a root, not only at the variable you just cleared.
Predict both lines. After draft = nil, can the program still reach the notice? Then add pinned := board[0] above board = nil, add pinned.Text as a second value in the last fmt.Println (Go rejects a variable that is never used), and predict again.
package main
import "fmt"
type Notice struct {
Text string
}
func main() {
board := []*Notice{}
draft := &Notice{Text: "fire drill at 3"}
board = append(board, draft)
draft = nil
fmt.Println(len(board), board[0].Text)
board = nil
fmt.Println(len(board))
}
What to look for
Watch main’s variables. Before draft = nil, draft shows an address, then → main.Notice {Text: "fire drill at 3"}. After draft = nil, draft shows *main.Notice nil, but board still shows len: 1 and the same address, → {Text: "fire drill at 3"}, so 1 fire drill at 3 prints. After board = nil, board shows len: 0, cap: 0, nil: no variable leads to the notice, and 0 prints. After the edit, pinned still shows the notice at the last stop, and 0 fire drill at 3 prints.
Expected output
1 fire drill at 3
003 / Cycles
What happens to values that refer to each other?
There are two common ways to reclaim memory. Reference counting keeps a count of the references to each value and frees the value when its count reaches zero. A tracing collector starts from the roots, marks everything it can reach, and reclaims the rest.
They differ on cycles: values that refer to each other, such as a meeting room that refers to its booking and a booking that refers to its room. With counting alone, each still has one reference, from the other, so neither count reaches zero. A tracing collector never reaches them from a root, so it reclaims both.
room := &Room{Name: name}
booking := &Booking{Team: team, Room: room}
room.Booking = booking // a cycle Go traces from the roots, so a cycle that no root reaches is reclaimed.
Coming from Python: CPython counts references and frees most objects as soon as the count reaches zero, with a separate cycle collector for cycles; Go does not count references at all.
Precisely: counting in other languages
Reference counting is common where memory must be released at a predictable moment. Swift counts references automatically, and Rust’s Rc type counts the owners of one value. Neither frees a cycle by itself: Swift code marks one side weak, and Rust code uses Weak for one direction. Go needs neither, because its collector traces.
Experiment
Drop two values that point at each other
The collector follows references from the roots. The Cedar room and its booking refer to each other, but no path from a root reaches either of them, so a tracing collector reclaims both. Counting references alone would find one reference to each and keep them.
Why it matters A cycle is not a leak for a tracing collector. Ask whether a root reaches the group, not whether its members refer to each other.
Predict what prints. After the second call, which rooms can the program still reach? Then add archive := current above the second call, add archive.Name as a third value in fmt.Println (Go rejects a variable that is never used), and predict again.
package main
import "fmt"
type Room struct {
Name string
Booking *Booking
}
type Booking struct {
Team string
Room *Room
}
func bookRoom(name string, team string) *Room {
room := &Room{Name: name}
booking := &Booking{Team: team, Room: room}
room.Booking = booking
return room
}
func main() {
current := bookRoom("Cedar", "design")
current = bookRoom("Birch", "sales")
fmt.Println(current.Name, current.Booking.Team)
}
What to look for
Inside bookRoom, after room.Booking = booking, room shows an address, then → main.Room {Name: "Cedar", Booking: 0x… → {Team: "design", Room: 0x…}}. The booking’s Room field is the same address as room, shown bare instead of opening room again: the two values point at each other. After the second call, current shows the Birch room, and no variable leads to the Cedar room or its booking. Birch sales prints. After the edit, archive still shows the Cedar room at the last stop, and Birch sales Cedar prints.
Expected output
Birch sales04 / Unused but reachable
Can a value you no longer use stay in memory?
Reachability has one consequence that surprises people. A value you will never use again stays in memory as long as something reachable still refers to it. This is called a memory leak, even though nothing was forgotten in the usual sense: the program still holds a path to the value.
In Go the long-lived thing is usually a package-level map or slice that only grows, a goroutine that never finishes, or a small slice of a large array. A slice expression such as minutes[:6] shares the backing array of minutes, and the collector frees a backing array only as a whole. So the six bytes keep all 1000 bytes reachable. To keep only the small part, copy it into a new slice.
A goroutine that never finishes is the other common leak. A goroutine that has not finished is a root, so one that waits for ever keeps everything its variables point at. Typical causes: a goroutine blocked on a channel send after the caller stopped waiting for the result, a time.Ticker that is never stopped, and a context from context.WithCancel whose cancel function is never called.
func firstWord(minutes []byte) []byte {
return minutes[:6] // shares all of minutes
}
title := append([]byte(nil), minutes[:6]...) // a copy A small slice can keep a whole backing array reachable. Copy the part you keep.
Coming from Java: Java’s String.substring has copied its characters since Java 7 update 6; a Go substring s[:n] still shares the bytes of s, so strings.Clone(s[:n]) is the copy.
Precisely: slots past the length
The collector scans every slot of a backing array, including slots past a slice’s length. So s = s[:0], or removing the last element with s = s[:len(s)-1], leaves the old pointers reachable until those slots are overwritten. clear(s) (Go 1.21) sets every element of s to its zero value, and slices.Delete has zeroed the slots it frees since Go 1.22. bytes.Clone and strings.Clone (Go 1.20) copy a slice or a string.
Experiment
Keep a large array reachable through a small slice
minutes[:6] makes a new slice that points into the same backing array. The garbage collector keeps a backing array as long as any slice points into it, and it never frees part of an array. append onto a nil slice copies the bytes into a new array of its own. In this short program, the compiler can keep the 1000-byte array in main’s stack frame, because no pointer to it outlives main (go build -gcflags=-m reports that it does not escape). If title were stored somewhere long-lived, such as a package-level slice, the array would be on the heap, and the collector would keep all 1000 bytes. cap shows what title uses in both cases.
Why it matters When you keep a small part of a large slice for a long time, copy it. Otherwise the small part keeps the whole backing array in memory.
Predict the three values printed. After minutes = nil, how much memory can the program still reach through title? Then change the return line to return append([]byte(nil), minutes[:6]...) and predict again.
package main
import "fmt"
func firstWord(minutes []byte) []byte {
return minutes[:6]
}
func main() {
minutes := make([]byte, 1000)
copy(minutes, "AGENDA: budget, hiring, office move")
title := firstWord(minutes)
minutes = nil
fmt.Println(string(title), len(title), cap(title))
}
What to look for
Delve loads only the first 64 elements of a long slice, as (loaded 64/1000) shows. After the call, title shows []uint8 len: 6, cap: 1000, [65,71,69,78,68,65]: the character codes of AGENDA. After minutes = nil, title is the only path left, and its cap shows that it still uses the whole 1000-byte backing array: AGENDA 6 1000 prints. After the edit, title shows cap: 8 and AGENDA 6 8 prints: the six bytes were copied into a new, small backing array, and nothing reaches the 1000-byte array any more. The cap is 8, not 6, because the runtime rounds each small allocation up to one of a fixed set of sizes, and 8 bytes is the smallest that holds 6.
Expected output
AGENDA 6 100005 / Timing
When is an unreachable value reclaimed?
Reachability decides which values may be reclaimed. The collector decides when. It may reclaim an unreachable value soon, much later, or never in a short program, and the program gets no signal when it happens.
So never rely on the garbage collector to release anything other than memory, such as an open file, a network connection, or a lock. Release those in your own code, at the point where you are finished with them. In Go, write defer f.Close() right after a successful open.
f, err := os.Open("visitors.txt")
if err != nil {
return err
}
defer f.Close() // not left to the garbage collector You choose when to close resources; the collector chooses when to reclaim memory.
Precisely: finalizers, cleanups, and runtime.GC
runtime.SetFinalizer, and runtime.AddCleanup (Go 1.24), register a function that the runtime may call some time after a value becomes unreachable, on a goroutine of its own. The documentation promises neither when it runs nor that it runs before the program exits. An *os.File has one that closes its descriptor, but only as a last resort. runtime.GC() forces a collection and is meant for tests and measurements. The weak package (Go 1.24) holds pointers that do not keep a value reachable.
06 / The implementation
What may the runtime change?
The language promises only that a value stays usable while it is reachable. How and when memory is reclaimed is the runtime’s choice.
The Go collector runs mostly at the same time as your goroutines. It marks what it can reach from the roots and then frees the rest. It does not move values in the heap, and it does not split the heap by age as V8 and most default Java collectors do. Two settings tune it: GOGC, how much the heap may grow between collections, and GOMEMLIMIT, a soft limit on total memory.
This is why section 02 says live variables: the compiler may treat a variable as no longer in use after its last use, even before its function returns. That is why runtime.KeepAlive exists: it marks a point until which a value must stay reachable, for code that depends on a finalizer.
Precisely: finding a leak in a real program
The runtime/pprof package, or net/http/pprof in a server, writes a heap profile that go tool pprof reads. It shows which code allocated the memory that is still in use, which points to the map or slice that keeps growing. A profile describes one run.
07 / Try it
Which values can the program still reach?
Predict the output, then follow every path from a root to each visitor.
package main
import "fmt"
type Visitor struct {
Name string
Host *Visitor
}
func main() {
signedIn := []*Visitor{}
ana := &Visitor{Name: "Ana"}
ben := &Visitor{Name: "Ben", Host: ana}
ana.Host = ben
signedIn = append(signedIn, ben)
ana, ben = nil, nil
carl := &Visitor{Name: "Carl"}
signedIn = signedIn[:0]
fmt.Println(len(signedIn), carl.Name)
} What prints? After line 16, which visitors can the program still reach? After line 18?
Reveal the reasoning
0 Carl. After line 16, ana and ben are nil, but Ben is still reachable through signedIn, and Ana through Ben’s Host. Line 18 sets the length of signedIn to 0, but signedIn still uses the same backing array, and its one slot still holds the pointer to Ben. The collector scans the whole backing array, not only the part within the length, so Ben and Ana stay reachable for as long as signedIn is. The stepper shows signedIn as len: 0, cap: 1, [], which hides that slot. Writing signedIn = nil, or clear(signedIn) before reslicing, would make both unreachable; they refer to each other, and the tracing collector would still reclaim them. Carl stays reachable through carl.
Practice tickets
- GC-101 · The front desk still holds visitors who left → Free
- GC-102 · The printer keeps notifying screens that were closed → Sign in · Pro
- GC-103 · Each tracking code keeps a whole scanned page in memory → Sign in · Pro
Keep exploring
I/O, buffering, and external state →