Learn Neovim - Debugging Using DAP (Debug Adapter Protocol - nvim-dap)
Series/Learn Neovim/Episode 22
Episode 22 of 28

Learn Neovim - Debugging Using DAP (Debug Adapter Protocol - nvim-dap)

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.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

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.

Main Discussion

The Concept: What Is the Debug Adapter Protocol (DAP)?

DAP is an open communication protocol defining how editors talk to debuggers. The architecture separates two components:

  1. Debug Adapter (debugger) — the program that actually runs and controls the debugged process. It knows how to set breakpoints, step, and read variable values from a specific runtime (Go, Python, Node.js, and others).
  2. DAP Client (editor) — the application where you work (Neovim, VS Code, and others). It talks to the debug adapter via a JSON protocol.

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.

plaintext
Neovim (DAP Client)  <--JSON-RPC-->  Debug Adapter (delve / debugpy / js-debug)  <-->  Proses aplikasi

Important

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.

Installing nvim-dap and nvim-dap-ui

lua/plugins/dap.lua
return {
  {
    "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.

Basic Debugging Navigation

Once installed, you can already do the following:

KeymapAction
<leader>dbToggle a breakpoint on the cursor line
<leader>dBSet 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>dcRun to cursor — run until the cursor line
<leader>drOpen 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.

Per-Language Debug Adapter Configuration

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.

Go: Delve (delve)

lua/config/dap-go.lua
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}",
  },
}
Instalasi Delve
go install github.com/go-delve/delve/cmd/dlv@latest

Python: debugpy

Pythonlua/config/dap-python.lua
local 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",
  },
}
Instalasi debugpy
python3 -m pip install debugpy

JavaScript/TypeScript: vscode-js-debug via js-debug-adapter

For JS/TS, use the same adapter that VS Code uses. Install the binary via mason.nvim (episode 15) using the js-debug-adapter package:

lua/config/dap-js.lua
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
Install adapter JS via Mason
:MasonInstall js-debug-adapter

Tip

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.

A Real Debugging Walkthrough

Let's practice a complete debugging flow on a simple Go program:

main.go
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:

  1. Open 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).
  2. Press <F5> to start debugging. The program runs until it reaches the breakpoint, then stops.
  3. The nvim-dap-ui panel opens automatically showing Variables, Call Stack, Breakpoints, and Watches.
  4. Press <F10> (step over) several times to trace the execution of the hitungTotal function. Watch the total value change in the Variables panel.
  5. To inspect an arbitrary expression, open the REPL with <leader>dr then type data[0] — Neovim evaluates it through the debug adapter.
  6. When debugging finishes, press <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.

Conditional Breakpoints & Watches

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:

  • Conditional breakpoint (<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.
  • Watch expression — a list of expressions evaluated continuously at every breakpoint, shown in the Watch panel. For example adding both 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.

Understanding the launch vs attach Requests

The two main DAP modes you must distinguish:

ModeDescriptionWhen to use
launchThe debugger runs a new program, attaching itself from the startDaily debugging of local files/tests
attachThe debugger connects to an already-running processBugs 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 Summary Table

KeymapAction
<leader>dbToggle breakpoint
<leader>dBConditional breakpoint
<F5>Continue / start debugging
<F10>Step over
<F11>Step into
<F12>Step out
<leader>dcRun to cursor
<leader>drOpen the REPL
<leader>dn / <leader>dpNavigate to the next/previous breakpoint

Common Pitfalls

MistakeSymptomSolution
Adapter not configuredError no adapter found when pressing <F5>Make sure dap.adapters.<type> is defined for the language in use
Debugger binary not installedError command not found: dlv / debugpyInstall via go install, pip, or :MasonInstall
Port binding conflict / cannot bindAdapter fails to start, port errorUse type = "executable" (Neovim spawns the binary) or a unique port
nvim-dap-ui error "module not found"UI does not open while debuggingMake sure dap-ui is installed together with nvim-dap as a dependency
Breakpoint inactive (called disabled)The program does not stop at the desired pointVerify the adapter loads the correct symbols (e.g. build with -gcflags=all=-N -l for Go in some cases)
Session cannot restart cleanlySecond debugging errors, old state hangsRun :DapTerminate / :DapRestart and close old sessions
Program does not find env varsThe app runs without environment configurationAdd env = { ... } in dap.configurations
dap-ui opens but panels are emptyVariables/Watches show nothingMake 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.

Closing

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!

Learn Neovim - Debugging Using DAP (Debug Adapter Protocol - nvim-dap) | Learn Neovim