1. When and why you need Delve
A debugger is often seen as a magic button: “make it work.” Alas, it doesn’t fix code; it only lets you observe execution in slow motion. But that slow motion has tremendous power: you can stop at exactly the right line, look at the real values of variables, and understand at which step your expectations diverged from reality.
Delve (the dlv command) is the standard debugger for Go. It’s useful when you already roughly know where the problem is (for example, from a stack trace), but you still don’t understand why the values ended up that way. At the same time, Delve isn’t always available (environment constraints, company policy, “I’m on Windows and today everything is against me”), so the skills of reading a stack trace and using diagnostic output still need to be “at hand.”
A simple mental model
So that Delve doesn’t feel mystical, it helps to keep a simple model in mind. Your program normally runs forward without stopping. The debugger lets you place “freeze frames” (breakpoints) to pause execution, and then either continue running or step line by line. At the pause point you can examine local variables, function parameters, the call stack, and sometimes even evaluate expressions.
An important psychological detail: debugging is not “watch everything,” it’s “test one hypothesis.” If you try to track 20 variables at once, your brain overheats faster than a laptop in summer sun. So we’ll learn to set breakpoints closer to the place where an invariant is violated and look at only a couple of key values.
2. Basic work in Delve
Minimal start
In real life, Delve is run either through an IDE (for example, GoLand/IntelliJ, VS Code) or directly from the command line. In this lecture it’s more important to understand the principles, so we’ll talk about dlv as an interactive console tool: you run the program under the debugger and communicate via commands. The IDE does the same thing; it’s just buttons, panels, and tables instead of commands.
The most common scenario is to run the current package like a normal program, but under debugger control. In the terminal this usually looks like a “debug build run,” after which you land in the Delve console and set breakpoints and control stepping there. If the program has command-line arguments, you can pass them too, but today the idea “we can start and stop in main” is enough.
Breakpoint
A breakpoint is a pause point. You tell the debugger: “if execution reaches this line (or the entry of this function), pause the program.” At that moment you can calmly inspect variables and understand what’s happening. This is far more precise than “sprinkle 15 fmt.Printf calls and then read a mile-long log like a 1200-page novel.”
There are two practical ways to set breakpoints: by function name and by the file:line location. At the start it’s convenient to set a breakpoint on main.main to catch the program right after launch. Then move the breakpoint closer to the problematic logic: right before a dangerous operation (indexing, pointer dereference, division, type assertion) or before a suspicious if branch.
A small table of “what a beginner typically needs about breakpoints” (not “memorize this,” but “know that it exists”):
| Intent | Command example in dlv (idea) | What happens |
|---|---|---|
| Stop at function entry | |
Pause on entry to main |
| Stop at a specific line | |
Pause before executing the line |
| Show the list of breakpoints | |
Shows what and where is set |
| Remove a breakpoint | |
Removes the pause point |
One important clarification right away: a “breakpoint on a line” usually means “stop before executing this line.” That’s normal: you have time to inspect values before the operation happens.
Stepping: next, step, finish
When the program stops on a breakpoint, the question is: what next? You can continue execution until the next breakpoint, or you can “step.” Stepping is the main joy of a debugger: you see how variable values change and can catch the moment when everything goes “off the rails.”
There are two fundamental types of step: “step over the line” and “step into a function call.” In debuggers these are typically called next and step. If the current line contains a function call, next executes the call as a single action (as if it were a black box), while step goes inside and shows the function executing line by line.
A useful mini-table for stepping:
| Command (idea) | Meaning | When it’s convenient |
|---|---|---|
|
run to the next breakpoint | when you already set a “trap” further down |
|
step to the next line without entering calls | when the called function isn’t “suspected” |
|
step into the called function | when you suspect it broke inside |
|
run the current function until it returns | when you’ve already seen what you need and want to “jump back out” |
There’s a nuance here that later saves your nerves: if you accidentally step into the standard library and see the internals of fmt or runtime, don’t panic. Usually finish is enough to return to your function, or set a breakpoint in your code and do continue.
Inspecting variables and expressions
The power of a debugger is that you stop guessing. At a pause point you can ask: what are the values of the function arguments? What’s inside the slice? What is len right now? Is the pointer nil? What exact type is inside interface{} (or any)? It’s like an X-ray: unpleasant only the first time; then it becomes a working tool.
Delve has commands that let you print expressions and view local variables. In an IDE these are usually the Variables, Locals, Watches tabs. In console style (idea) it looks like print expr, locals, args. It’s important to remember that you can print not only “the whole variable,” but also expressions: len(tasks), tasks[i].ID, u == nil, and so on.
A very typical investigation pattern looks like this: you stop on the line before a suspicious operation, print 2–3 values, confirm that one of them is unexpected (for example, the index equals len(slice), not len(slice)-1), and you end up with a concrete reason for the bug rather than a mystical “well, it sometimes crashes.”
Why stepping may “jump”: optimizations and inlining
When you debug a program, you expect execution to follow the text strictly. But the Go compiler is smart and loves to optimize. It can simplify expressions, reorder computations, remove temporary variables, and inline small functions right into the call site. For performance this is great. For step-by-step debugging it can be odd: you press step, and the cursor seems to “jump” or “not go” where you expected.
To make debugging more predictable, people often use a debug build without optimizations and without inlining. In Delve this is typically done via build flags like -gcflags "all=-N -l". The idea is simple: -N reduces optimizations, -l disables inlining. You don’t have to memorize these flags like a spell; it’s enough to remember the meaning: if the debugger behaves strangely, try running a “simpler” build.
Separately: this is not a “magic correctness mode.” It makes the program slower and closer to the source text, but bugs don’t disappear from it—only stepping surprises do.
3. Mini-scenario: catching a pointer bug
Let’s tie Delve to something real. Imagine we keep developing a small console task app (a hypothetical taskapp). It has a Task struct and a function that returns a list of pointers to tasks because “we want to modify them through pointers” (a typical beginner idea—and sometimes it really is needed).
Suppose we have this code (it compiles, looks decent… and at the same time contains a classic trap of reusing a variable):
package taskapp
type Task struct {
ID int
Text string
}
func ToPointers(tasks []Task) []*Task {
var res []*Task
var t Task
for _, x := range tasks {
t = x
res = append(res, &t)
}
return res
}
If you call ToPointers([]Task{{1,"a"},{2,"b"}}), you expect two different pointers to two different tasks. But in practice you often get “two pointers to the same place,” because the t variable is the same one, and each time you append the address of that same variable. As a result, all elements of res point to one object (the last assigned t).
This is a great candidate for debugging in Delve, because it’s easy to “miss” when reading the code, but in the debugger you can see addresses and values at each step.
To make it more convenient to observe, let’s add a small usage example (in a separate main package, so we can run it):
package main
import (
"fmt"
"example/taskapp"
)
func main() {
tasks := []taskapp.Task{{ID: 1, Text: "a"}, {ID: 2, Text: "b"}}
ptrs := taskapp.ToPointers(tasks)
fmt.Println(ptrs[0].ID, ptrs[1].ID) // expected: 1 2 (but sometimes you’ll see: 2 2)
}
Now the debugging scenario. We need to stop inside ToPointers and check: what address are we appending to res on each iteration, and what’s stored at that address.
The action logic in Delve is usually like this:
1) Set a breakpoint on taskapp.ToPointers
2) Run continue to stop inside the function
3) Step through the loop and inspect:
- the value of t
- the address &t
- what ended up in res
If you show it as a “session snippet” (very simplified, with no claim to be a perfect copy of the output), the thoughts and commands look roughly like this:
(dlv) break taskapp.ToPointers
(dlv) continue
(dlv) next
(dlv) print &t
(dlv) next
(dlv) print &t
The key check here is exactly &t. If you see that the address is the same on both iterations, the puzzle fits: you append the same pointer twice.
After that, the fix is usually simple: you should take the address of the slice element, not a temporary variable. For example, using an index-based loop:
package taskapp
func ToPointers(tasks []Task) []*Task {
res := make([]*Task, 0, len(tasks))
for i := range tasks {
res = append(res, &tasks[i])
}
return res
}
And here Delve is useful not only to “find the problem,” but also to confirm that the fix really fixed it: after the change, you set the breakpoint again, inspect &tasks[i] on each iteration, and see different addresses.
4. How to think about debugging with Delve
To avoid turning debugging into chaotic “click-click-click on buttons,” it helps to have one stable cycle of actions. It’s very similar to what we did with print-debugging, except the tools are richer now.
flowchart TD
A[There is a symptom: panic / wrong result] --> B[Hypothesis: where expectations diverge]
B --> C[Set a breakpoint close to the problem area]
C --> D[Stop and inspect 2-3 key values]
D --> E{Hypothesis confirmed?}
E -- yes --> F[Fix the code and re-check]
E -- no --> G[Refine the hypothesis and move the breakpoint]
G --> C
If you keep this scheme in mind, Delve stops being a “complicated tool for adults” and becomes simply an accelerator for testing hypotheses.
5. Typical mistakes when working with Delve
Mistake #1: trying to debug without a hypothesis, just “watching variables.”
This is the most common path to fatigue. You stop, see dozens of values, and don’t understand what matters. It’s much more productive to formulate a short question in advance: “why did id become 0?”, “why is len(tasks) equal to 0?”, “why is cfg == nil?”. Then you’ll inspect exactly 2–3 values, not the whole world.
Mistake #2: setting a breakpoint too early and getting a thousand stops.
If you set a pause point “at the start of the program” and then step forward, you can grow old before you reach the bug. Usually it’s more useful to set a breakpoint closer to where an invariant is violated: right before a dangerous line or before the branch that leads to the failure. The more precise the breakpoint, the less unnecessary noise.
Mistake #3: getting stuck in the standard library and thinking it’s a dead end.
step on a call to fmt.Println can take you into the weeds of string formatting, and beginners sometimes end up living there. If you see that you’re “not in your code,” it’s not a failure. Usually it’s enough to do finish to exit the current function, or set a breakpoint in your file and press continue.
Mistake #4: being surprised by “weird stepping” because of optimizations and inlining.
Sometimes it feels like the debugger is “lying”: a line is skipped, a variable “doesn’t exist,” jumps don’t match expectations. Often this is not a Delve bug, but a compiler-optimization effect. In such cases, a debug build with optimizations and inlining disabled helps (the idea behind the -N -l flags). If you see strange behavior, don’t conclude “I can’t debug”—first check the build mode.
Mistake #5: trying to “fix via the debugger” by changing values at random.
Some debuggers let you modify variable values on the fly. This can be useful, but for beginners it often turns into self-deception: “I changed the variable and it worked.” Yes, but you didn’t fix anything in the code. Use the debugger to understand the root cause, then apply the fix in the source and verify by running again.
GO TO FULL VERSION