skip to content

Breakpoints and Stepping

Optimised Go binaries confuse a debugger: inlined frames disappear and locals read as optimised out, so you rebuild with -N -l before stepping and switching between goroutines.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

When you step through a plain `go build` binary, why do locals read as optimized out, and which build flags fix it?

level: juniorimportance: must knowfreq 50%

answer

  1. your build is lying about the source
  2. the value never reached memory
  3. the frame you wanted was inlined away
  4. two compiler switches, plus a package pattern

basics

~20 s

Go's compiler keeps locals in registers and inlines small calls, so the variable and the stack frame a debugger looks for do not exist at runtime. Rebuild with go build -gcflags="all=-N -l" to disable optimisation and inlining before stepping.

solid answer

~50 s

By default the Go compiler optimises: a local can live only in a register or be eliminated entirely, so the debugger has no address to read and reports it as optimized out, and a small function gets inlined into its caller so the frame you tried to step into never exists. Instruction scheduling also makes the cursor jump between source lines. Rebuilding with `go build -gcflags="all=-N -l"` fixes both: `-N` turns off optimisations so every local gets a real stack slot and the line table matches the source statement by statement, and `-l` turns off inlining so every call is a real call. The `all=` package pattern is the part people forget — without it the flags apply only to the packages named on the command line, leaving every dependency you step into fully optimised. Never ship or benchmark that binary.

code

text · 6 lines
text
# release build: locals may read as <optimized out>, small callees vanish into their callers
go build -o fixtool ./cmd/fixtool

# debuggable build: -N disables optimisation, -l disables inlining,
# and the all= pattern applies both to every package in the build
go build -gcflags="all=-N -l" -o fixtool.debug ./cmd/fixtool

go deeper

for a junior

Memorise the flag and what each half does: -N disables optimisation, -l disables inlining, and go build -gcflags="all=-N -l" is the incantation. Be ready to say that a variable reading as optimized out is the compiler's doing, not a broken tool.

for a middle

Explain the mechanism: register allocation and dead-store elimination remove the address a debugger needs, and inlining removes the frame. Be ready to explain what the all= package pattern changes and why quoting the flags matters.

for a senior

Show the judgment around it: a -N -l binary is a local artefact with a distinct name, never a benchmark target and never a release. Say when you would reach for a rebuild versus a stack dump or a log line on a machine you cannot rebuild for.

for a principal

Frame it as a build-policy question. Decide whether debug builds are a documented, one-command path for onboarding engineers, and make sure the optimised build a team ships and the debug build they reason about cannot silently diverge.

## Why an optimised Go binary is hostile to a debugger A debugger works from a map the compiler leaves behind: *this machine instruction belongs to that source line*, and *this variable lives at that address, in that register, over this instruction range*. An optimising compiler is allowed to change the program as long as the observable behaviour is the same — and "observable" does not include what a debugger sees. Three optimisations account for nearly every confusing session. **Register allocation and dead-store elimination.** A local variable does not need a stack slot. If the compiler can keep it in a CPU register for its whole lifetime, or prove that an assignment is never read afterwards, there is no memory location to point at when you stop. Asked for the value, the debugger honestly answers that it was optimised out. Nothing is broken; there is simply nothing to read at that instruction. **Inlining.** A small function called from one place is copied into the body of its caller and the call instruction disappears. The frame you wanted to step into is not merely hidden — it never exists at runtime. "Step into" walks straight past it, and the stack you see is shallower than the source suggests. **Instruction scheduling.** Instructions from several statements are interleaved, so consecutive "next" commands land on line 42, then 38, then 45. The line table is not wrong; the machine code genuinely belongs to those lines in that order. ## The build that fixes it ``` go build -gcflags="all=-N -l" -o fixtool.debug ./cmd/fixtool ``` `-gcflags` forwards flags to the compiler. The two switches do different jobs, and confusing them is a standard interview tell: - `-N` **disables optimisations.** Every local gets a stack slot with a stable address; assignments happen where you wrote them; the line table is statement-for-statement. - `-l` **disables inlining.** Every call in the source is a call at runtime, so the frames you expect are on the stack and stepping into a helper works. ## The `all=` prefix `-gcflags` accepts an optional **package pattern** before the flags. Without one, the flags apply only to the packages *named on the command line*. `go build -gcflags="-N -l" ./cmd/fixtool` therefore produces one unoptimised package linked against a fully optimised standard library and fully optimised dependencies. That is usually enough to make the session look fixed — until you step into a decoder in `encoding/json` to watch a struct field get populated, and every value there reads as optimised out again. `all=` is the pattern that means every package in the build, including the standard library. Keep the quotes: unquoted, the shell hands `-l` to `go build` itself, which has no such flag and errors out. ## What the debug build costs A `-N -l` binary is slower — sometimes dramatically, because hot small functions that would have been inlined now pay call overhead and everything spills to memory — and it is larger. It also occupies a different entry in the build cache, so the first such build recompiles the world. Two consequences follow: - **Never measure performance on it.** Timings and allocation counts from a `-N -l` build describe a program nobody runs. - **Never ship it.** It is a local artefact; give it a distinct output name (`-o fixtool.debug`) so it cannot be confused with the release binary. ## Test binaries and stopping without a breakpoint The same flags apply to a test binary: `go test -c -gcflags="all=-N -l"` compiles a debuggable test executable instead of running the tests. If a spot is awkward to reach by hand — a rare branch, a callback deep inside a decode — `runtime.Breakpoint()` compiles to a breakpoint trap. Under a debugger the process stops there; with no debugger attached the process dies of SIGTRAP, so it is strictly a temporary edit, never something you commit. ## Symptom to cause | What you see | What caused it | |---|---| | A local reads as optimized out | It lived in a register or was eliminated; rebuild with `-N` | | Step into skips a function | The callee was inlined; rebuild with `-l` | | The cursor jumps backwards between lines | Instruction scheduling; `-N` restores statement order | | Your own package steps fine, dependencies do not | The `all=` prefix was missing | | A breakpoint on a function name will not resolve | Debug information was stripped at link time, a separate problem from optimisation |

  • Why write `all=-N -l` instead of just `-N -l`?
    `-gcflags` takes an optional package pattern before the flags, and without one the flags apply only to the packages named on the command line. `go build -gcflags="-N -l" ./cmd/fixtool` leaves every dependency, including the standard library, fully optimised — so the moment you step into a decoder the values read as optimised out again. `all=` means every package in the build.
  • What does a `-gcflags="all=-N -l"` build cost, and where must it never be used?
    It is slower and larger: nothing is inlined, values spill to stack slots, and it occupies its own build-cache entry so the first build recompiles everything. Never benchmark on it — the numbers describe a program nobody runs — and never ship it. Give it a distinct output name so it cannot be mistaken for the release artefact.
  • How do you stop the program at a spot that is awkward to reach by hand?
    Call `runtime.Breakpoint()` on the branch you care about; it compiles to a breakpoint trap, so an attached debugger stops there without you resolving a line number. With no debugger attached the process dies of SIGTRAP, which makes it a temporary local edit only — it must never be committed.

saying these in an interview costs you the question

  • Thinks -N and -l do the same job
  • Omits the all= prefix and wonders why dependencies stay opaque
  • Benchmarks a -gcflags=all=-N -l build and reports the numbers
  • Ships the debug build to production
  • Assumes a debugger can always show any local variable
  • Blames the debugger rather than the optimiser for the missing value
open as a page

How do you build a Go test binary you can step through, and how do you pass `-run` to it once compiled?

level: middleimportance: should knowfreq 40%

basics

~10 s

Compile the package's tests without running them: go test -c -gcflags="all=-N -l" -o decode.test ./internal/fixture. Running that binary directly, the testing flags carry a test. prefix, so filtering is -test.run rather than -run.

open as a page

Stepping over a channel receive in a Go program, why does the debugger resume in a different goroutine?

level: seniorimportance: should knowfreq 32%

basics

~20 s

Because the goroutine you were stepping blocked, and Go's runtime scheduler immediately put a different goroutine on that OS thread. A breakpoint address is process-wide, so you must use goroutine-aware stepping and switch goroutines explicitly rather than trusting the current thread.

open as a page

What does the Go linker emit for debuggers by default, and what do `-ldflags="-s -w"` strip?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

By default the Go linker writes DWARF debug information plus the symbol table into the binary, which is what lets a debugger resolve names, types and source lines. Linking with -ldflags="-w" drops the DWARF and -s drops the symbol table.

open as a page