Debugging in Go has a clear hierarchy: tests first (most bugs are caught here), the race detector second (finds data races before they cause production incidents), pprof profiles third (finds performance and memory issues), and the Delve debugger last (for when you need to step through running code). This guide covers all four layers.
For profiling-specific patterns see Go performance tuning systems.
Delve: The Go Debugger
Install and use Delve for interactive debugging:
go install github.com/go-delve/delve/cmd/dlv@latest
# Debug a main package
dlv debug ./cmd/server
# Debug a specific test
dlv test ./internal/service -- -run TestCreateOrder
# Attach to a running process
dlv attach <pid>
Essential Delve commands:
(dlv) break main.handleOrder # set breakpoint by function name
(dlv) break server.go:42 # set breakpoint by file:line
(dlv) condition 1 req.Amount > 1000 # conditional breakpoint
(dlv) continue # run until next breakpoint (c)
(dlv) next # step over (n)
(dlv) step # step into (s)
(dlv) stepout # step out of current function (so)
(dlv) print req # print variable value (p)
(dlv) locals # print all local variables
(dlv) args # print function arguments
(dlv) stack # print call stack
(dlv) goroutines # list all goroutines
(dlv) goroutine 5 # switch to goroutine 5
(dlv) watch -w req.UserID # watchpoint: break when req.UserID changes
(dlv) list # show source at current position
(dlv) quit # exit
For VS Code: install the Go extension; set breakpoints by clicking the gutter; press F5 to debug.
Printf Debugging Done Right
When you need quick visibility without a full debugger session, structured log output is more useful than plain fmt.Println:
// Enable debug logging via environment variable
var debug = os.Getenv("DEBUG") != ""
func debugf(format string, args ...any) {
if debug {
_, file, line, _ := runtime.Caller(1)
// Include file:line so you can find it later
fmt.Fprintf(os.Stderr, "[DEBUG %s:%d] %s\n",
filepath.Base(file), line, fmt.Sprintf(format, args...))
}
}
// Usage
debugf("processing order %s: amount=%d, items=%d", order.ID, order.Amount, len(order.Items))
Run with DEBUG=1 go run . to enable, blank env to disable. The runtime.Caller(1) adds the file:line automatically.
For temporary debugging in tests, t.Logf is preferable over fmt.Println — it’s shown only when the test fails:
func TestProcessOrder(t *testing.T) {
result, err := processOrder(req)
t.Logf("result: %+v, err: %v", result, err) // only shown on failure
// ...
}
Race Detector
The race detector instruments memory accesses to find concurrent data races. Run it during development and in CI:
go test -race ./...
go run -race main.go
go build -race -o server_race ./cmd/server
When a race is detected, you get a detailed report showing both conflicting goroutines with full stack traces:
==================
WARNING: DATA RACE
Write at 0x00c0001a4050 by goroutine 8:
main.(*Cache).Set()
/app/cache.go:23 +0x68
Previous read at 0x00c0001a4050 by goroutine 7:
main.(*Cache).Get()
/app/cache.go:17 +0x45
==================
The race detector has ~5–10x overhead — appropriate for tests, too slow for production. Any race found is a real bug, not a false positive.
Detecting Goroutine Leaks
Goroutines that never exit accumulate, consuming stack memory. Use goleak in tests to catch them:
go get go.uber.org/goleak
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) // fails if any goroutines leak during tests
}
// Or per-test
func TestMyFunc(t *testing.T) {
defer goleak.VerifyNone(t)
// ... test code ...
}
To detect leaks in production, watch the goroutine count gauge in Prometheus, or expose it:
http.HandleFunc("/debug/goroutines", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "goroutines: %d\n", runtime.NumGoroutine())
// More detail:
pprof.Lookup("goroutine").WriteTo(w, 1)
})
A goroutine count that grows with traffic but never decreases is a leak.
Diagnosing Deadlocks
Go detects deadlocks at runtime when all goroutines are blocked:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive]:
main.main()
/app/main.go:15 +0x68
For partial deadlocks (some goroutines blocked, others running), dump all goroutine stacks:
# Send SIGQUIT to get a full goroutine dump
kill -QUIT <pid>
# Or in tests: press Ctrl+\ to trigger
The output shows the blocking reason for each goroutine: chan receive, chan send, sync.Mutex.Lock, semacquire, etc.
Add this to your /debug/pprof endpoint to inspect goroutines via HTTP:
import _ "net/http/pprof" // registers /debug/pprof/goroutine
// GET /debug/pprof/goroutine?debug=2 shows full stack traces
Memory Leak Diagnosis
Memory leaks in Go are reference leaks — something keeps referencing data that should be garbage collected. Common sources and fixes:
// ❌ Growing global map — common source of leaks
var sessions = map[string]*Session{}
// sessions["user1"] = s // never deleted
// ✅ Use expiring cache or bounded map
var sessions = ttlcache.New[string, *Session](ttlcache.WithTTL[string, *Session](30 * time.Minute))
// ❌ Sub-slice keeps huge backing array alive
bigData := loadFile() // 1GB
result := bigData[0:100] // still holds 1GB backing array
// ✅ Copy the small portion
result := make([]byte, 100)
copy(result, bigData[:100])
// bigData can now be GC'd
Use pprof to find what’s retaining memory:
# Take a heap profile
go tool pprof http://localhost:6060/debug/pprof/heap
# In pprof shell
(pprof) top20 -inuse_space # top 20 by retained bytes
(pprof) top20 -inuse_objects # top 20 by retained object count
(pprof) list packagename.FuncName # line-level view of a specific function
(pprof) web # open flame graph in browser
Take two profiles 60 seconds apart, compare with pprof -diff_base:
curl http://localhost:6060/debug/pprof/heap > heap1.prof
sleep 60
curl http://localhost:6060/debug/pprof/heap > heap2.prof
go tool pprof -diff_base heap1.prof heap2.prof
# Shows what was allocated between the two snapshots
go tool trace
The execution tracer provides fine-grained timing of goroutine scheduling, GC, and syscalls — useful when pprof shows a hotspot but you need to understand why:
import "runtime/trace"
f, _ := os.Create("trace.out")
trace.Start(f)
defer trace.Stop()
// ... run the code you want to trace ...
go tool trace trace.out # opens a browser-based timeline
Key trace views:
- Goroutine analysis: where each goroutine spends time (running, waiting on network, waiting on GC)
- Scheduler trace: how goroutines are scheduled across OS threads
- GC trace: timing of GC stop-the-world pauses
Summary
- Delve:
dlv debug, set breakpoints,print var,goroutines— watch for theconditionflag for conditional breakpoints go test -racefinds data races; any reported race is a real buggoleak.VerifyTestMaincatches goroutine leaks during testingkill -QUITdumps all goroutine stacks — look for blocked goroutines in deadlock diagnosis- Heap pprof +
top20 -inuse_spacefinds memory leaks; diff two profiles to see what grew go tool traceprovides microsecond-level scheduling visibility when pprof isn’t enough
Comments