← Runtimes
Runtime walkthrough 7 questions · 6 languages

Stack and heap

A correct model of calls, refined for how long values last.

A call ending, the program losing its last way to reach a value, and that value’s memory being released are three different events. The stack-and-heap picture explains the first event correctly. It explains the other two only in part.

Before you begin

Why this matters

Knowing when a value stops being usable explains why Go can safely return a pointer to a local variable, and why some variables cost more than others.

What this helps you reason about

  • Predict whether a value is still usable after the call that created it returns.
  • Explain why returning &local is safe in Go.
  • Read the compiler’s own report of where it kept each variable, as evidence about one build.

The stack-and-heap picture is a correct, useful model of how calls work, and this lesson starts from it. It uses the picture to predict real behavior, then refines it where Go needs more. The compiler is the program that translates your Go code into machine instructions. In Go, the compiler, not your code, decides where each variable lives.

Already know this? The short version
  • Each call has a frame for its parameters and locals. The frame is removed on return, and only the return value reaches the caller.
  • Returning &local is always safe: escape analysis moves any variable that can be used after its call returns to the heap. Closures keep the variables they use alive in the same way.
  • Placement affects cost, not correctness. go build -gcflags="-m" shows one build’s decisions, -m=2 adds the reasons, and go test -benchmem or testing.AllocsPerRun measures allocations.
  • The garbage collector reclaims values some time after they are unreachable from running goroutines and package-level variables. Files and connections still need Close, usually with defer.

Go to the final check· Go to the practice tickets

Words in this lesson (8)
frame
The record of one function call that has not finished: its parameters and local variables. It is created when the call starts and removed when the call returns.
call stack
The frames of all calls in one goroutine that have not finished yet, with the newest on top. A new call adds a frame on top, and a return removes it. Only the newest call runs; each call below it waits for the call it made.
heap
An area of memory for values that must outlive the call that created them. The garbage collector manages it.
pointer
A value that holds the address of a variable, written *int for a pointer to an int. &count makes a pointer to count, and *p reads or changes the variable that p points to.
escape analysis
The compiler’s check of whether a variable can still be used after its function returns. A variable that can is said to escape, and the compiler stores it on the heap.
closure
A function literal together with the variables from the surrounding function that it uses. Those variables last as long as the closure does.
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.
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.

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 do people mean by stack and heap?

Here is the picture most programmers learn. It is a teaching model: a simple picture that explains real behavior. Each function call gets a frame, a block of memory for its parameters and local variables. The frames are kept on the stack, also called the call stack. A new call adds a frame on top of the stack, and returning removes that frame.

Some values must stay usable after the call that created them returns, for example an object that the call gives back to its caller. A frame cannot hold such a value, because the frame is removed when the call returns. So those values are kept in a separate area of memory, called the heap, until the program no longer needs them.

Go runs code in goroutines. A goroutine is a function that runs alongside the rest of the program, and main runs in one. Each goroutine has its own stack. That stack starts small, a few kilobytes, and the runtime makes it larger when calls need more room, by copying it to a bigger block of memory. So a goroutine’s frames can move while it runs. An operating-system thread, such as a Java thread, gets a stack of fixed size when it starts.

This model is correct for many programs. The rest of this lesson uses it to predict behavior, then refines it for the cases it does not cover.

Frames hold one call’s variables; the heap holds values that may be needed after their call returns. In Go, the compiler decides which is which.

02 / Frames

What happens to a call’s variables when it returns?

A frame exists only while its call is running. When inner returns, its frame is removed and doubled is gone. outer receives only the returned value.

main.go
func inner(n int) int {
    doubled := n * 2
    return doubled
}

A call’s local variables end when it returns; its return value does not.

Experiment

Watch a frame come and go

Each call gets a frame holding its parameters and local variables. A frame exists only while its call is running. When inner returns, its frame is removed; only the returned value reaches outer.

Why it matters Local variables belong to one call. Once the call returns, the only thing the caller has is the value that was returned.

Predict what prints. Step until inner is running, then step once more. What happens to doubled?

package main

import "fmt"

func inner(n int) int {
    doubled := n * 2
    return doubled
}

func outer() int {
    start := 4
    result := inner(start)
    return result + 1
}

func main() {
    fmt.Println(outer())
}
What to look for

Watch the frame list. outer appears, then inner on top of it. When inner returns, its frame and its doubled variable are gone, and outer continues with result.

Expected output

9

03 / Outliving a call

How can a value last longer than the call that made it?

A pointer is a value that holds the location of a variable, such as &value, and the Variables lesson showed that copying one gives a second way to reach the same variable. This section asks how long that variable lasts.

In Go, a function can return a pointer to its own local variable, and the caller can keep using it. The language guarantees this is safe. Each call declares a new value, so a second call gives a second variable.

main.go
func makeCounter() *int {
    value := 3
    return &value
}
first := makeCounter()
*first = 9
second := makeCounter() // a new variable: *second is 3

Two returned pointers. This is a model of what each pointer reaches, not of memory addresses.

A conceptual model, not a physical memory layout or live code execution.

1 / 3

Declare

value A
3
&value
points to A

makeCounter declares value A.

A variable lasts as long as a pointer to it can still be used, not as long as the call that declared it.

Coming from C: In C, returning the address of a local variable is a bug; in Go it is always safe, because the compiler keeps the variable alive.

Experiment

Keep a variable after its call returns

Go lets a function return the address of its own local variable. Each call declares a new value, and the compiler keeps that variable alive for as long as a pointer to it can be used.

Why it matters In Go, returning &value is always safe, and each call gives a new variable. Whether the compiler keeps value in the frame or somewhere longer-lived is its decision.

Predict both numbers: does the second call give back the first call’s variable, or a new one? Then change second := makeCounter() to second := first and predict again.

package main

import "fmt"

func makeCounter() *int {
    value := 3
    return &value
}

func main() {
    first := makeCounter()
    *first = 9
    second := makeCounter()
    fmt.Println(*first, *second)
}
What to look for

Step past the first return. The makeCounter frame is gone, but first still shows an address followed by → 3, then → 9. The second call declares a new value, so second shows a different address followed by → 3, and 9 3 prints. After the edit, makeCounter runs once, second shows the same address as first followed by → 9, and 9 9 prints.

Expected output

9 3

04 / Refining the picture

Where does the picture need refining?

Here the picture needs refining. In the picture, value is a local, so it should end with the frame. In Go, the compiler checks whether a variable can still be used after its call returns. If it can, the compiler stores the variable on the heap, where it lasts longer. This check is called escape analysis, and such a variable is said to escape.

Closures work the same way. A closure is a function literal that uses variables from the function around it. When makeCounter returns the closure below, count escapes and keeps living.

main.go
func makeCounter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

In Go, any local can outlive its call; the compiler decides where it lives.

Coming from Java: The HotSpot JVM also does escape analysis, but invisibly while the program runs; Go does it when compiling, and the compiler can print every decision.

Coming from Java: A Go closure uses the variable itself, not a copy of its value, and there is no effectively-final rule: the closure can assign to count, and the change is seen everywhere count is used.

Experiment

Keep a local alive in a closure

The function literal uses count, so count must last as long as the function does. The compiler stores such a variable where it can outlive the call.

Why it matters The picture “locals live in the frame” needs refining here. In Go, any local can outlive its call if something still uses it.

Predict all three numbers. Then change fmt.Println(next(), next(), next()) to fmt.Println(next(), makeCounter()(), next()) and predict again. makeCounter() returns a new function, and the second () calls it at once.

package main

import "fmt"

func makeCounter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

func main() {
    next := makeCounter()
    fmt.Println(next(), next(), next())
}
What to look for

After makeCounter returns, main shows next as func() int {count: 0}: the function literal’s type and the count it uses. Step into it. Go names its frame main.makeCounter.func1, and it still shows count, even though the makeCounter frame is gone. After the edit, 1 1 2 prints: the middle call has its own count.

Expected output

1 2 3

05 / Releasing memory

Who decides when memory can be reused?

You never free memory yourself in Go. The Go runtime is the code that Go adds to every program to manage goroutines and memory. It includes a garbage collector: a part that finds memory the program can no longer use and makes it available again.

A value is reachable when the program can get to it by following pointers from the variables of calls that are still running, or from package-level variables. The garbage collector reuses the memory of values that are no longer reachable. Values that refer only to each other are not reachable, so they are reclaimed too.

Other resources, such as open files and network connections, are not memory. Close them yourself, usually with defer.

Memory is reclaimed some time after nothing the program can still reach refers to a value; files and connections still need Close.

Coming from Python: Go has no with statement; write defer f.Close() right after a successful open, and it runs when the surrounding function returns, not at the end of a block.

Coming from Java: Go has one garbage collector, without generations or a choice of collectors, and it is tuned mainly with two settings: GOGC, how much the heap may grow between collections, and GOMEMLIMIT, a soft limit on total memory.

06 / The implementation

What may the compiler or runtime change?

Whether a variable is stored in a frame or on the heap affects speed, not correctness. A heap variable costs more: it must be allocated, and later the garbage collector must find it. You can ask the compiler what it decided for one build.

The answer describes one build, not the source code. For the check program in section 07, the compiler reported that count moves to the heap in makeCounter itself, but not in the two places where it copied makeCounter’s body into main. Copying a function’s body into the code that calls it is called inlining. So the same source got two answers. The report can also change between Go versions, so do not make a test depend on its exact text.

Precisely: read the compiler’s decisions

Save the check program from section 07 as example.go and run go build -gcflags=-m example.go. Go 1.27, in the stepper’s container, printed the lines below. Read them in two groups.

The first group is about inlining. can inline makeCounter means the compiler may copy makeCounter’s body into the code that calls it; inlining call to makeCounter at lines 14 and 17 means it did so at both calls in main. The compiler still builds makeCounter as an ordinary, separate function too, for any other code that calls it.

The second group is about escape. Lines 6 and 7, moved to heap: count and func literal escapes to heap, describe that separate makeCounter: it returns the closure to code it cannot see, so count must outlive the call. Lines 14 and 17, func literal does not escape, describe the two copies inside main: there the compiler can see that neither closure leaves main, so it moves no count to the heap. tally() escapes to heap is about the copy of tally’s result that fmt.Println receives as an any value. Storing a value in an interface, as here, is one common cause of escape; another is make with a size that is not a constant.

The stepper builds with inlining and optimization turned off. With those settings, go build -gcflags="-N -l -m" example.go printed only four lines: moved to heap: count, func literal escapes to heap, ... argument does not escape, and tally() escapes to heap. The same source gave two placements for count, so each run describes one build with one compiler version.

With -gcflags=-m=2, the compiler also prints why each value escapes, as a chain of flow: lines; for count, the reason is “captured by a closure”. -gcflags=-m applies only to the packages named on the command line; -gcflags=all=-m applies it to every package, including the standard library, and prints thousands of lines. To check whether an allocation really went away, measure it: go test -bench . -benchmem reports allocations per operation, and testing.AllocsPerRun counts them inside an ordinary test.

main.go
$ go build -gcflags=-m example.go
# command-line-arguments
./example.go:5:6: can inline makeCounter
./example.go:7:12: can inline makeCounter.func1
./example.go:14:25: inlining call to makeCounter
./example.go:17:24: inlining call to makeCounter
./example.go:18:16: inlining call to fmt.Println
./example.go:6:5: moved to heap: count
./example.go:7:12: func literal escapes to heap
./example.go:14:25: func literal does not escape
./example.go:17:24: func literal does not escape
./example.go:18:16: ... argument does not escape
./example.go:18:22: tally() escapes to heap

07 / Try it

When can each value’s memory be released?

Predict the output, then follow each count: which one can the program still reach after line 17?

main.go
package main

import "fmt"

func makeCounter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

func main() {
    tally := makeCounter()
    tally()
    tally()
    tally = makeCounter()
    fmt.Println(tally())
}

What prints? After line 17, which count can the program still reach, and when can the memory of the other one be reused?

Reveal the reasoning

1. Each call to makeCounter declares a new count and returns a new function literal that uses it. Line 17 stores the second function in tally; its count starts at 0, so 1 prints. After line 17, nothing the program can still reach refers to the first function or its count, which had reached 2. The garbage collector may reuse that memory at a time it chooses, not at line 17. Both makeCounter frames ended when their calls returned, but neither count ended with its frame. Whether each count was on the heap at all is the compiler’s decision for one build; section 06 shows it.

Practice tickets

  1. STK-101 · Keep each shelf label as it was printed → Free
  2. STK-102 · Hand out a receipt snapshot that stays as it was → Sign in · Pro
  3. STK-103 · Open one connection and close it → Sign in · Pro

Keep exploring

Investigate a minimal reproduction →

Primary references