Skip to main content

Debugging System-Level Issues in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 5 min read

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 the condition flag for conditional breakpoints
  • go test -race finds data races; any reported race is a real bug
  • goleak.VerifyTestMain catches goroutine leaks during testing
  • kill -QUIT dumps all goroutine stacks — look for blocked goroutines in deadlock diagnosis
  • Heap pprof + top20 -inuse_space finds memory leaks; diff two profiles to see what grew
  • go tool trace provides microsecond-level scheduling visibility when pprof isn’t enough

Resources

Comments

👍 Was this article helpful?