How to Debug Go Programs with Delve (dlv)

Debug Go programs by running `dlv debug` to attach the debugger, set breakpoints, and step through code execution.

How to Debug Go Programs with Delve (dlv)

You are staring at a panic. The stack trace points to a nil pointer dereference inside a helper function, but the variable looked fine when you printed it earlier. You added fmt.Println everywhere, but the output is interleaved with other log lines, and the program crashes before you can read the state. You restart the service, wait for the request, and the crash happens again. You need to freeze the program at the exact line where the value goes wrong, inspect the memory, and understand why the logic failed. Delve gives you that control.

What Delve does

Delve is the standard debugger for Go. It connects to your program and lets you pause execution, inspect variables, and step through code line by line. It works by reading the debug information embedded in your binary. Go compilers include this information by default, so your binaries are ready to debug without extra flags. Delve replaces guesswork with precision. You stop guessing what a variable holds and see the truth.

Think of Delve as a pause button for your code. You pause the program, look at the state, maybe change a variable to test a hypothesis, then resume. It is like stepping out of a moving car to check the engine while the car stays running, then getting back in and driving again. Most editors integrate Delve, so you often click buttons in VS Code or GoLand. Knowing the underlying commands helps when the UI gets stuck or when you are debugging over SSH with just a terminal.

Minimal example

Here is a small program that sums even numbers. It looks correct, but debugging helps verify the state at every step without guessing.

package main

import "fmt"

// ProcessData filters and sums even numbers from a slice.
func ProcessData(items []int) int {
    total := 0
    for _, item := range items {
        if item%2 == 0 {
            total += item // add only even numbers to the accumulator
        }
    }
    return total
}

func main() {
    input := []int{1, 2, 3, 4, 5}
    result := ProcessData(input)
    fmt.Println("Sum of evens:", result) // print the final result
}

Run the debugger with dlv debug .. The terminal opens a Read-Eval-Print Loop with a (dlv) prompt. The program is paused before main starts. Type break ProcessData to set a breakpoint inside the function. Type continue to run until the breakpoint. The program stops at the first line of ProcessData.

Type next to execute the next line. The loop starts. Type print item to see the current value. Type print total to see the accumulator. Type locals to list all local variables in the current scope. Type list to view the source code around the current line. Step through the loop a few times to watch total update. When you are done, type continue to finish the program.

Walking through the REPL

The Delve REPL supports commands that give you full control over execution. break sets a breakpoint. You can use break main to stop at the entry point, or break ProcessData:5 to stop at a specific line. continue runs the program until the next breakpoint or until it exits. next executes the next line, stepping over function calls. step executes the next line, stepping into function calls. print evaluates an expression and shows the result. locals prints all local variables. vars prints package-level variables. stack shows the call stack, which helps you understand how the program reached the current line.

You can set conditional breakpoints with break ProcessData if item == 3. The program stops only when item equals 3. This is useful for debugging loops that run many times. You can also evaluate expressions that modify state. Type print total = 100 to change the value of total. The program resumes with the new value. This lets you test hypotheses without changing the source code.

Delve also supports execution replay. Recent versions allow you to record a run and step backward in time. This is an ah-ha feature for complex bugs. You can run the program to the crash, then step backward to find the exact moment a variable changed. The command revert moves execution backward. This turns debugging into a time machine.

Delve is the official debugger. Trust the REPL when the UI confuses you.

Debugging a realistic HTTP handler

Here is a realistic HTTP handler. Debugging web servers requires setting breakpoints before the server starts listening, or attaching to a running process.

package main

import (
    "fmt"
    "net/http"
)

// HandleRequest processes an HTTP request and returns a greeting.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        http.Error(w, "missing name", http.StatusBadRequest)
        return
    }
    fmt.Fprintf(w, "Hello, %s", name)
}

func main() {
    http.HandleFunc("/", HandleRequest)
    fmt.Println("Server starting on :8080")
    http.ListenAndServe(":8080", nil)
}

Run dlv debug .. The program pauses at main. Type break HandleRequest to set a breakpoint inside the handler. Type continue. The server starts and prints the startup message. The program is now waiting for requests. Open a terminal and run curl "http://localhost:8080/?name=Alice". Delve stops inside HandleRequest.

Type print name to see the query parameter. Type print r.URL to inspect the full URL. Type print r.Context().Err() to check if the request was cancelled. In Go, context.Context is always the first parameter in functions that support cancellation. HTTP requests carry a context that tracks deadlines and cancellation. When debugging, checking the context error helps you understand if the client disconnected or if a timeout occurred.

Type next to step through the logic. If you want to see the response, you can step over fmt.Fprintf and check the writer state, though HTTP writers are opaque. Type continue to send the response and return to the REPL. The server is still running. You can make more requests and hit the breakpoint again. Type exit to stop the debugger and the server.

Convention aside: Go functions that take a context should respect cancellation. When debugging, always check ctx.Err() if a function returns early or behaves unexpectedly. The context is plumbing. Run it through every long-lived call site.

Inspecting goroutines

Go programs often have multiple goroutines. Delve lets you switch between them to inspect concurrent execution.

package main

import (
    "fmt"
    "time"
)

// Worker processes items from a channel.
func Worker(id int, jobs <-chan int) {
    for j := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, j)
        time.Sleep(10 * time.Millisecond) // simulate work
    }
}

func main() {
    jobs := make(chan int, 100)
    for i := 1; i <= 3; i++ {
        go Worker(i, jobs) // spawn three worker goroutines
    }
    for j := 1; j <= 5; j++ {
        jobs <- j
    }
    close(jobs)
    time.Sleep(100 * time.Millisecond) // wait for workers to finish
}

Run dlv debug .. Type break Worker to set a breakpoint inside the worker function. Type continue. The program stops in one of the worker goroutines. Type goroutines to list all goroutines. The output shows the ID, status, and current location of each goroutine.

Type goroutine 2 to switch to goroutine 2. The debugger context changes. Type stack to see the call stack of goroutine 2. Type print id to see the worker ID. You can inspect each goroutine independently. This is crucial for finding deadlocks or goroutine leaks. If a goroutine is stuck waiting on a channel, stack shows the channel operation. You can inspect the channel state to see if it is closed or buffered.

Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Always have a cancellation path. Delve shows you the goroutines that are stuck in silence.

Pitfalls and errors

Delve requires debug information in the binary. If you build with go build -ldflags="-s -w", the symbols are stripped. Delve fails with an error like could not find DWARF information. The compiler rejects debugging attempts when the binary lacks the map to source code. Build without stripping symbols when you need to debug.

Attaching to a running process with dlv attach <pid> may fail with operation not permitted. This happens when the operating system restricts ptrace access. On Linux, check sysctl kernel.yama.ptrace_scope. If the value is non-zero, you may need to adjust permissions or run the debugger as root. This is a security feature, not a bug.

Optimizations used to break debuggers in older Go versions. Developers had to build with go build -gcflags="-N -l" to disable optimizations. Modern Go and Delve handle optimized binaries well. You do not need to disable optimizations anymore. The compiler preserves enough information for Delve to work.

If you try to debug a program that does not compile, Delve runs the compiler first and shows the error. You see messages like cannot use x as string in argument or undefined: pkg. Fix the compilation errors before debugging. Delve cannot run broken code.

Symbols are the map. Strip them and you are lost.

When to use Delve

Use dlv debug . when you are developing locally and want to run the program under control from the start. Use dlv attach <pid> when a running process is misbehaving and you need to inspect its state without restarting. Use dlv test ./... when you want to debug a specific test case by setting breakpoints inside the test function. Use structured logging when you need to diagnose issues in production where attaching a debugger is impossible. Use fmt.Println for quick, throwaway checks during early prototyping when setting up a debugger feels like overkill. Use the editor's debug integration when you prefer a visual interface with variable watches and step buttons over typing commands.

Debuggers for control. Logs for survival.

Where to go next