- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You know the loop. A test fails on the third goroutine out of
fifty. You sprinkle fmt.Println("here", x) through the code,
run it, read the wall of output, add three more prints, run it
again. Twenty minutes later you have found the bug and left a
minefield of debug lines you have to clean up before you commit.
Go has a real debugger, and it was built by people who work on
Go. Delve understands
goroutines, the Go runtime, and Go's calling conventions in a way
that GDB never did. This is a tour of the four things that make it
worth the muscle memory: breakpoints, conditional breakpoints,
goroutine inspection, and attaching to a process that is already
running.
Getting Delve
Install the dlv binary with go install:
// from your shell, not a .go file
go install github.com/go-delve/delve/cmd/dlv@latest
Check it works:
dlv version
Delve builds your program with optimizations and inlining turned
off (-gcflags="all=-N -l") so the line numbers and variables
match your source. You do not pass those flags yourself; dlv
does it for you.
The example we will debug is small on purpose: a worker pool that
processes orders and, on one specific order, does the wrong thing.
Here is the whole program we will run under Delve.
package main
import (
"fmt"
"sync"
)
type Order struct {
ID int
Total int
}
func process(o Order) int {
fee := o.Total / 10
if o.ID == 37 {
fee = fee * -1 // the planted bug
}
return o.Total + fee
}
func main() {
orders := make([]Order, 0, 50)
for i := 0; i < 50; i++ {
orders = append(orders, Order{ID: i, Total: 100})
}
var wg sync.WaitGroup
results := make([]int, len(orders))
for i, o := range orders {
wg.Add(1)
go func(idx int, ord Order) {
defer wg.Done()
results[idx] = process(ord)
}(i, o)
}
wg.Wait()
fmt.Println("total:", sum(results))
}
func sum(xs []int) int {
t := 0
for _, x := range xs {
t += x
}
return t
}
Breakpoints
Start a debug session on the package in the current directory:
dlv debug
That drops you at a (dlv) prompt with the program compiled but
not started. Set a breakpoint on the process function:
(dlv) break process
Breakpoint 1 set at 0x... for main.process() ./main.go:14
You can also break on a file and line, which is what you reach for
most of the time:
(dlv) break main.go:16
Now run until the first hit:
(dlv) continue
Execution stops at the breakpoint. Look at what you have:
(dlv) args
o = main.Order {ID: 0, Total: 100}
(dlv) print o.Total
100
(dlv) next
(dlv) locals
fee = 10
The core stepping commands are short:
-
continue(c) — run to the next breakpoint. -
next(n) — step over the current line. -
step(s) — step into a function call. -
stepout(so) — run until the current function returns. -
print x(p x) — evaluate an expression.
One thing fmt.Println cannot do: change state while stopped.
Delve can. set writes to a variable in the live program:
(dlv) set fee = 5
(dlv) print fee
5
That alone pays for learning the tool. You can test a fix
hypothesis without editing, rebuilding, and rerunning.
Conditional breakpoints
The worker pool spawns fifty goroutines. The bug is on order 37.
If you continue through a plain breakpoint, you stop fifty times
and read fifty frames looking for the one that matters. That is
worse than the print loop.
A conditional breakpoint fires only when an expression is true.
Set the breakpoint, then attach a condition to it:
(dlv) break process
Breakpoint 1 set at 0x... for main.process() ./main.go:14
(dlv) condition 1 o.ID == 37
(dlv) continue
Delve runs past every order except 37, then stops. Confirm you
landed where you wanted:
(dlv) print o.ID
37
(dlv) next
(dlv) next
(dlv) next
(dlv) print fee
-10
There it is. fee went negative on order 37. You found the exact
input that triggers the bug without reading a single frame you did
not care about.
You can set the condition in one line with break:
(dlv) break main.go:16 o.ID == 37
Delve also has hit-count conditions through the condition -hitcount
form, so you can break on the 100th call to a hot function:
(dlv) condition -hitcount 1 > 99
Inspecting goroutines
This is where Delve leaves every other approach behind. When the
program is stopped, goroutines lists every live goroutine:
(dlv) goroutines
* Goroutine 1 - User: ./main.go:36 main.main
Goroutine 6 - User: ./main.go:33 main.main.func1
Goroutine 7 - User: ./main.go:33 main.main.func1
...
The * marks the goroutine you are currently on. Switch to
another one by its ID and its stack becomes your context:
(dlv) goroutine 7
Switched from 1 to 7
(dlv) stack
0 0x... in main.process
at ./main.go:16
1 0x... in main.main.func1
at ./main.go:33
From there, frame moves up and down the stack, and print
reads locals in whichever frame you selected:
(dlv) frame 1
(dlv) print ord
main.Order {ID: 37, Total: 100}
For a leak hunt, the count matters. If you expect the pool to
drain and it does not, goroutines shows the ones still parked and
stack tells you exactly which line they are blocked on. A parked
channel receive, a sync.WaitGroup that never reaches zero, a
mutex nobody released: the stack names the line. No amount of
printing gets you that view of the whole runtime at once.
Attaching to a running process
The scenarios above assume you launched the program under Delve.
Production does not work that way. The process is already up, it is
misbehaving, and restarting it under a debugger would clear the
state you need to see.
Delve attaches to a running process by PID. Find it, then attach:
// find the pid
pgrep myservice
// attach (may need sudo, or ptrace_scope tuning on Linux)
dlv attach 48213
When you attach, Delve pauses the process. Every goroutine
freezes where it is. Now the same commands work: goroutines to
see what is running, stack to see where a stuck one is blocked,
print to read live state. When you continue, the process
resumes; when you detach (or answer no to the kill prompt on
quit), the process keeps running.
Two operational notes. On Linux, the kernel's
ptrace_scope setting often blocks attaching to a process you did
not start; you either run dlv as root or lower the scope. And
attaching pauses the process, so on a live service you are trading a
short stall for a look inside. Know that before you attach to
something taking traffic.
There is also a headless mode for remote debugging. Start Delve as
a server on the machine where the process lives:
dlv attach 48213 --headless --listen=:2345 --api-version=2
Then connect from your laptop with dlv connect host:2345 or point
your editor's debug client at that port. That is how you debug a
process inside a container: run the headless server next to it, map
the port out, connect from where your source lives.
Where fmt.Println still wins
Delve is not a total replacement. A print statement that logs on
every request, running in production for a week, catches the
intermittent bug that never shows up while you are watching. A
debugger cannot sit and wait for a Tuesday-only race. Structured
logging and tracing own that job.
The split is about time. Reach for the debugger when you can
reproduce the problem now and want to see the state directly. Reach
for logs when the problem is rare, slow, or only happens in an
environment you cannot attach to. Most Go developers over-index on
prints because they never learned the other half. Now you have.
Start small: next time you would add a fmt.Println, run
dlv debug instead, set one breakpoint, and print the variable.
The muscle memory comes fast, and you stop shipping debug lines you
forgot to delete.
Delve understands goroutines because it understands the runtime,
and the runtime is exactly where most Go debugging goes wrong. The
Complete Guide to Go Programming digs into the scheduler,
goroutine states, and the calling conventions Delve reads, so the
goroutines and stack output stops being a mystery. Hexagonal
Architecture in Go is the companion for keeping your code in shape
where you rarely need the debugger in the first place — clean
boundaries mean fewer places for state to go sideways.

Top comments (0)