In this episode we turn Neovim into an interactive debugger on par with GUI IDEs using the Debug Adapter Protocol: breakpoints, step over/into/out, variable and call stack inspection, plus debugger configuration for Go, Python, and JavaScript/TypeScript.

After integrating AI coding assistants to write code faster in episode 21, in this episode we will build the capability that actually makes the code you write accountable: interactive debugging. We will learn the Debug Adapter Protocol (DAP) and implement it in Neovim using nvim-dap and nvim-dap-ui, so you can set breakpoints, trace execution line by line, and inspect variables — exactly like in VS Code or IntelliJ.
Why is this topic so important to understand? There is a myth that print debugging — adding fmt.Println() or console.log() here and there — is sufficient. It is practical, but it has hidden costs: you have to guess which points need logging, wait for the process to finish, then read the accumulating output. For complex bugs — e.g. race conditions, state changing in many places, or asynchronous logic — print debugging turns into slow, exhausting detective work. An interactive debugger changes this paradigm: you run the program with full control, stop at the desired points, and observe its internal state at that very moment.
As a DevOps engineer, this ability is also important for understanding code you did not write yourself — other services, migration scripts, or internal tooling. The debugger is the fastest way to understand a program's real behavior. Let's start with the protocol concept.
DAP is an open communication protocol defining how editors talk to debuggers. The architecture separates two components:
This is analogous to the LSP we learned in episode 15: just as LSP separates language intelligence from the editor, DAP separates debugging intelligence from the editor. In other words, Neovim does not need to know how to debug Go or Python internally — it only needs to speak DAP, and the language-specific debug adapter handles the rest.
Neovim (DAP Client) <--JSON-RPC--> Debug Adapter (delve / debugpy / js-debug) <--> Proses aplikasiImportant
A fundamental difference from LSP: LSP analyzes code (statically), while DAP runs code (dynamically). LSP answers the question "what is the definition of this function?", DAP answers "why does this function return the wrong value?" — because DAP actually stops execution and inspects the runtime state. The two complement each other, not replace each other.
nvim-dap and nvim-dap-uireturn {
{
"mfussenegger/nvim-dap",
keys = {
{ "<F5>", function() require("dap").continue() end, desc = "DAP: Continue" },
{ "<F10>", function() require("dap").step_over() end, desc = "DAP: Step Over" },
{ "<F11>", function() require("dap").step_into() end, desc = "DAP: Step Into" },
{ "<F12>", function() require("dap").step_out() end, desc = "DAP: Step Out" },
{ "<leader>db", function() require("dap").toggle_breakpoint() end, desc = "DAP: Toggle breakpoint" },
{ "<leader>dB", function() require("dap").set_breakpoint(vim.fn.input("Breakpoint condition: ")) end,
desc = "DAP: Conditional breakpoint" },
{ "<leader>dr", function() require("dap").repl.open() end, desc = "DAP: Open REPL" },
{ "<leader>dc", function() require("dap").run_to_cursor() end, desc = "DAP: Run to cursor" },
},
},
{
"rcarriga/nvim-dap-ui",
dependencies = { "mfussenegger/nvim-dap" },
config = function()
local dapui = require("dapui")
dapui.setup()
require("dap").listeners.after.event_initialized["dapui_config"] = function()
dapui.open()
end
require("dap").listeners.after.event_terminated["dapui_config"] = function()
dapui.close()
end
require("dap").listeners.after.event_exited["dapui_config"] = function()
dapui.close()
end
end,
},
}nvim-dap is the core engine (managing debugging sessions and protocol communication), while nvim-dap-ui is the display (variable panel, call stack, breakpoints, and REPL). Both must be installed together.
Warning
nvim-dap-ui must depend on nvim-dap — do not install dap-ui without nvim-dap. The UI display only works because it listens to events emitted by nvim-dap like event_initialized, event_terminated, and event_exited. If nvim-dap is absent, dap-ui will error on load. This dependency is non-negotiable.
Once installed, you can already do the following:
| Keymap | Action |
|---|---|
<leader>db | Toggle a breakpoint on the cursor line |
<leader>dB | Set a conditional breakpoint (only stops if the condition is met) |
<F5> | Start/continue debugging |
<F10> | Step over — execute this line then stop at the next line |
<F11> | Step into — enter the function call on this line |
<F12> | Step out — finish the current function then return to the caller |
<leader>dc | Run to cursor — run until the cursor line |
<leader>dr | Open the REPL to evaluate expressions |
Tip
Notice the step debugging pattern: Step Over (F10) skips over the function body and stops at the next line; Step Into (F11) enters the function called on that line; Step Out (F12) exits the current function. The analogy is like reading a book: Step Over reads only the front page, Step Into opens the referenced footnote, Step Out returns to the main flow.
The most important part (and the most often forgotten) is configuring the debug adapter for the language in use. Without this configuration, <F5> will give an error because Neovim does not know which debugger to run.
delve)local dap = require("dap")
dap.adapters.delve = {
type = "server",
port = "${port}",
executable = {
command = "dlv",
args = { "dap", "-l", "127.0.0.1:${port}" },
},
}
dap.configurations.go = {
{
type = "delve",
name = "Debug",
request = "launch",
program = "${fileDirname}",
},
{
type = "delve",
name = "Debug test",
request = "launch",
mode = "test",
program = "${fileDirname}",
},
}go install github.com/go-delve/delve/cmd/dlv@latestlocal dap = require("dap")
dap.adapters.python = {
type = "executable",
command = "python3",
args = { "-m", "debugpy.adapter" },
}
dap.configurations.python = {
{
type = "python",
name = "Debug file",
request = "launch",
program = "${file}",
console = "integratedTerminal",
},
{
type = "python",
name = "Debug tests",
request = "launch",
module = "pytest",
args = { "-v" },
console = "integratedTerminal",
},
}python3 -m pip install debugpyvscode-js-debug via js-debug-adapterFor JS/TS, use the same adapter that VS Code uses. Install the binary via mason.nvim (episode 15) using the js-debug-adapter package:
local dap = require("dap")
dap.adapters["pwa-node"] = {
type = "server",
host = "127.0.0.1",
port = 9229,
executable = {
command = "node",
args = {
vim.fn.stdpath("data") .. "/mason/packages/js-debug-adapter/js-debug/src/dapDebugServer.js",
},
},
}
dap.configurations.javascript = {
{
type = "pwa-node",
name = "Debug current file",
request = "launch",
program = "${file}",
cwd = vim.fn.getcwd(),
},
}
dap.configurations.typescript = dap.configurations.javascript
dap.configurations.javascriptreact = dap.configurations.javascript
dap.configurations.typescriptreact = dap.configurations.javascript:MasonInstall js-debug-adapterTip
If you use mason.nvim to manage DAP adapters, also consider the helper plugin mason-nvim-dap, which automatically connects binaries installed in Mason to the nvim-dap configuration — you do not need to write dap.adapters manually for the adapters it supports.
Let's practice a complete debugging flow on a simple Go program:
package main
import "fmt"
func hitungTotal(angka []int) int {
total := 0
for _, n := range angka {
total += n
}
return total
}
func main() {
data := []int{1, 2, 3, 4}
hasil := hitungTotal(data)
fmt.Println("Total:", hasil)
}The steps:
main.go, place the cursor on the hasil := hitungTotal(data) line then press <leader>db to set a breakpoint (a red circle appears in the gutter).<F5> to start debugging. The program runs until it reaches the breakpoint, then stops.nvim-dap-ui panel opens automatically showing Variables, Call Stack, Breakpoints, and Watches.<F10> (step over) several times to trace the execution of the hitungTotal function. Watch the total value change in the Variables panel.<leader>dr then type data[0] — Neovim evaluates it through the debug adapter.<F5> again (continue) until the program completes, or press <leader>dc for run-to-cursor. The dap-ui panel closes automatically.Note
When the program stops at a breakpoint, you can still move the cursor over variables and use vim.api.nvim_win_get_cursor to read their values — but the most comfortable way is using hover on the variable name in the Variables panel. Some adapters also support hover to inspect when the mouse hovers over a variable.
Running debugging with a breakpoint on every line is a naive way of working and wastes time on large codebases. Two DAP features you must master are conditional breakpoints and watch expressions:
<leader>dB) — the breakpoint only triggers when an expression is met. The most real example: in a loop of 10,000 iterations, you only want to stop when index == 9999 or when err != nil. This avoids pressing <F10> repeatedly.len(data) and data[0], so while debugging runs you immediately see both without having to retype them in the REPL every time the program stops.Note that conditional breakpoints are evaluated by the debug adapter itself, not by Neovim. As a result, the expression syntax follows the debugger's language — Go expressions are written in Go syntax, Python expressions in Python syntax. Errors in those expressions are only detected when debugging starts, not when you type them.
launch vs attach RequestsThe two main DAP modes you must distinguish:
| Mode | Description | When to use |
|---|---|---|
launch | The debugger runs a new program, attaching itself from the start | Daily debugging of local files/tests |
attach | The debugger connects to an already-running process | Bugs that only appear in staging/containers, or a process already running in debug mode |
For attach, the target program usually has to be run in debugging mode first — for example Python with debugpy.listen(...) inside the code, or Node.js with --inspect. After that, Neovim just points DAP at the opened host and port.
| Keymap | Action |
|---|---|
<leader>db | Toggle breakpoint |
<leader>dB | Conditional breakpoint |
<F5> | Continue / start debugging |
<F10> | Step over |
<F11> | Step into |
<F12> | Step out |
<leader>dc | Run to cursor |
<leader>dr | Open the REPL |
<leader>dn / <leader>dp | Navigate to the next/previous breakpoint |
| Mistake | Symptom | Solution |
|---|---|---|
| Adapter not configured | Error no adapter found when pressing <F5> | Make sure dap.adapters.<type> is defined for the language in use |
| Debugger binary not installed | Error command not found: dlv / debugpy | Install via go install, pip, or :MasonInstall |
| Port binding conflict / cannot bind | Adapter fails to start, port error | Use type = "executable" (Neovim spawns the binary) or a unique port |
nvim-dap-ui error "module not found" | UI does not open while debugging | Make sure dap-ui is installed together with nvim-dap as a dependency |
| Breakpoint inactive (called disabled) | The program does not stop at the desired point | Verify the adapter loads the correct symbols (e.g. build with -gcflags=all=-N -l for Go in some cases) |
| Session cannot restart cleanly | Second debugging errors, old state hangs | Run :DapTerminate / :DapRestart and close old sessions |
| Program does not find env vars | The app runs without environment configuration | Add env = { ... } in dap.configurations |
| dap-ui opens but panels are empty | Variables/Watches show nothing | Make sure debugging is actually paused at a breakpoint, not running fully |
Caution
In isolated corporate environments (no internet access to Go/PyPI registries), all debugger binaries must be pre-installed when building the development image/container. Never postpone installing dlv, debugpy, or js-debug-adapter until the debugging session — an unavailable dependency at runtime will destroy your workflow at the worst moment.
Tip
For debugging applications running in remote containers or Kubernetes, consider the request = "attach" pattern instead of launch. With attach, the debugger connects to an already-running process — very useful for inspecting bugs that only appear in the staging environment. Make sure the debugger port is exposed and the firewall allows the connection.
In episode 22 we turned Neovim into an interactive debugger on par with GUI IDEs: understanding the Debug Adapter Protocol and the client-adapter architecture that separates debugging intelligence from the editor, installing nvim-dap as the core engine and nvim-dap-ui as the variable/call stack/breakpoint panel display, configuring debug adapters for Go (Delve), Python (debugpy), and JavaScript/TypeScript (vscode-js-debug), and practicing a complete debugging walkthrough with breakpoints, step over/into/out, and the REPL.
With this capability, you no longer need to switch to another IDE just for debugging. Neovim is now a complete production-grade development environment: fast editing, integrated terminal, AI assistance, and interactive debugging — all in one place.
We have completed Phase 5 of the Learn Neovim series. In episode 23, we will enter Phase 6 and cover Profile & Performance Optimization — measuring startup time, finding slow plugins, and ensuring Neovim still starts within 50ms even with dozens of plugins. Stay motivated!