Neovim Debugging: A Complete Developer Guide
Modern Neovim is far more than a text editor — it is a fully capable debugging environment. With the Debug Adapter Protocol (DAP) and a handful of carefully chosen plugins, you can set breakpoints, step through code, inspect variables, and evaluate expressions without ever leaving your editor. This guide walks you through everything you need to set up and master debugging in Neovim.
What Is Neovim Debugging?
Neovim debugging relies on the Debug Adapter Protocol (DAP), a standardized protocol originally designed for VS Code. DAP decouples the debugger frontend (your Neovim UI) from the backend (language-specific debuggers like delve for Go, debugpy for Python, or node-debug2 for JavaScript). This means you can debug virtually any language that has a DAP-compatible adapter, all within Neovim.
The core plugin that makes this possible is nvim-dap, which implements the DAP client inside Neovim. On top of it, you can layer nvim-dap-ui for a graphical debugging interface, nvim-dap-virtual-text for inline variable display, and mason.nvim to manage debug adapter installations.
Why Debugging Inside Neovim Matters
- No context switching — Stay in your editor with all your keybindings, splits, and buffers intact
- Keyboard-first workflow — Every debugging action has a mappable Lua function, no mouse required
- Unified configuration — Debug adapter configs live alongside the rest of your Neovim dotfiles
- Lightweight and fast — No heavy IDE to launch, no Electron overhead
- Customizable to the extreme — Every aspect of the debugging experience can be tailored
Installation and Setup
You will need a modern Neovim (0.9+), a plugin manager like lazy.nvim, and optionally Mason to handle external tool installations. Below is a minimal but complete setup.
First, install the core plugins using lazy.nvim:
-- ~/.config/nvim/lua/plugins/debugging.lua
return {
-- Core DAP client
{
"mfussenegger/nvim-dap",
dependencies = {
"nvim-neotest/nvim-nio", -- async I/O library required by nvim-dap
},
config = function()
-- Key mappings will go here
end,
},
-- Beautiful UI for DAP
{
"rcarriga/nvim-dap-ui",
dependencies = {
"mfussenegger/nvim-dap",
"nvim-neotest/nvim-nio",
},
config = function()
local dap = require("dap")
local dapui = require("dapui")
dapui.setup()
-- Automatically open/close UI when debugging starts/stops
dap.listeners.after.event_initialized["dapui_config"] = function()
dapui.open()
end
dap.listeners.before.event_terminated["dapui_config"] = function()
dapui.close()
end
dap.listeners.before.event_exited["dapui_config"] = function()
dapui.close()
end
end,
},
-- Virtual text for variable values
{
"theHamsta/nvim-dap-virtual-text",
config = function()
require("nvim-dap-virtual-text").setup({
enabled = true,
highlight_changed_variables = true,
})
end,
},
-- Mason to manage debug adapters
{
"williamboman/mason.nvim",
build = ":MasonUpdate",
config = function()
require("mason").setup()
end,
},
-- Bridge between Mason and nvim-dap
{
"jay-babu/mason-nvim-dap.nvim",
dependencies = { "williamboman/mason.nvim", "mfussenegger/nvim-dap" },
config = function()
require("mason-nvim-dap").setup({
automatic_setup = true, -- auto-configure adapters from Mason
handlers = {},
})
end,
},
}
After installing, you can use Mason to pull in debug adapters. Open Mason with :Mason and install the adapters you need, such as:
- debugpy — Python debugging
- delve — Go debugging
- node-debug2 or js-debug — JavaScript/TypeScript
- codelldb — Rust, C, C++ via LLDB
- php-debugger — PHP with Xdebug
Key Mappings for Debugging
These mappings give you a complete keyboard-driven debugging workflow. Add them to your nvim-dap configuration:
-- Inside the nvim-dap config function
local dap = require("dap")
-- Basic debugging
vim.keymap.set("n", "<F5>", dap.continue, { desc = "Debug: Start/Continue" })
vim.keymap.set("n", "<F1>", dap.step_into, { desc = "Debug: Step Into" })
vim.keymap.set("n", "<F2>", dap.step_over, { desc = "Debug: Step Over" })
vim.keymap.set("n", "<F3>", dap.step_out, { desc = "Debug: Step Out" })
vim.keymap.set("n", "<leader>db", dap.toggle_breakpoint, { desc = "Toggle Breakpoint" })
vim.keymap.set("n", "<leader>dB", function()
dap.set_breakpoint(vim.fn.input("Breakpoint condition: "))
end, { desc = "Conditional Breakpoint" })
vim.keymap.set("n", "<leader>dl", function()
dap.set_breakpoint(nil, nil, vim.fn.input("Log message: "))
end, { desc = "Logpoint (no break)" })
-- Variable inspection
vim.keymap.set("n", "<leader>dr", dap.repl.open, { desc = "Open DAP REPL" })
vim.keymap.set("n", "<leader>dv", function()
dap.ui.widgets.hover(nil, { focused_body = true })
end, { desc = "Show variable under cursor in floating window" })
-- Scopes in a sidebar-like widget
vim.keymap.set("n", "<leader>ds", function()
local widgets = require("dap.ui.widgets")
widgets.centered_float(widgets.scopes, { width = 0.45, height = 0.3 })
end, { desc = "Show scopes in centered float" })
Configuring Debug Adapters
Each language needs an adapter configuration. While mason-nvim-dap can auto-configure many adapters, you may want manual control. Here are configurations for common languages:
Python (debugpy)
-- ~/.config/nvim/lua/dap-config/python.lua
local dap = require("dap")
dap.adapters.python = {
type = "executable",
command = vim.fn.stdpath("data") .. "/mason/packages/debugpy/venv/bin/python",
args = { "-m", "debugpy.adapter" },
options = { detached = true },
}
dap.configurations.python = {
{
name = "Python: Attach",
type = "python",
request = "attach",
connect = {
port = 5678,
host = "localhost",
},
pathMappings = {
{
localRoot = "${workspaceFolder}",
remoteRoot = ".",
},
},
},
{
name = "Python: Launch File",
type = "python",
request = "launch",
program = "${file}",
console = "integratedTerminal",
justMyCode = true,
env = {
PYTHONPATH = "${workspaceFolder}",
},
},
{
name = "Python: Launch with Args",
type = "python",
request = "launch",
program = "${file}",
args = function()
local argument_string = vim.fn.input("Arguments: ")
return vim.split(argument_string, " ", { trimempty = true })
end,
console = "integratedTerminal",
},
{
name = "Python: Pytest Current File",
type = "python",
request = "launch",
module = "pytest",
args = { "${file}", "-v", "-s", "--tb=short" },
console = "integratedTerminal",
},
}
JavaScript / TypeScript (vscode-js-debug)
-- ~/.config/nvim/lua/dap-config/javascript.lua
local dap = require("dap")
-- Using js-debug from Mason
dap.adapters["pwa-node"] = {
type = "server",
host = "localhost",
port = "${port}",
executable = {
command = vim.fn.stdpath("data") .. "/mason/packages/js-debug/js-debug/src/dapDebugServer.js",
args = { "${port}" },
detached = true,
},
}
dap.configurations.javascript = {
{
name = "Node: Launch File",
type = "pwa-node",
request = "launch",
program = "${file}",
cwd = "${workspaceFolder}",
runtimeExecutable = "node",
console = "integratedTerminal",
sourceMaps = true,
},
{
name = "Node: Attach to Process",
type = "pwa-node",
request = "attach",
port = 9229,
cwd = "${workspaceFolder}",
},
{
name = "Node: Launch with NPM",
type = "pwa-node",
request = "launch",
runtimeExecutable = "npm",
runtimeArgs = { "run", "start" },
cwd = "${workspaceFolder}",
console = "integratedTerminal",
},
}
dap.configurations.typescript = dap.configurations.javascript
Go (delve)
-- ~/.config/nvim/lua/dap-config/go.lua
local dap = require("dap")
dap.adapters.delve = {
type = "server",
port = "${port}",
executable = {
command = vim.fn.stdpath("data") .. "/mason/packages/delve/dlv",
args = { "dap", "-l", "127.0.0.1:${port}" },
detached = true,
},
}
dap.configurations.go = {
{
name = "Go: Launch Package",
type = "delve",
request = "launch",
program = "${workspaceFolder}/main.go",
buildFlags = "",
args = function()
local input = vim.fn.input("Args: ")
return vim.split(input, " ", { trimempty = true })
end,
env = {
GOFLAGS = "-buildvcs=false",
},
},
{
name = "Go: Launch Test",
type = "delve",
request = "launch",
mode = "test",
program = "${fileDirname}",
args = { "-test.run", "MyTest" },
},
{
name = "Go: Attach to Process",
type = "delve",
request = "attach",
mode = "local",
processId = function()
return vim.fn.input("PID: ")
end,
},
}
Rust / C / C++ (codelldb)
-- ~/.config/nvim/lua/dap-config/rust.lua
local dap = require("dap")
dap.adapters.codelldb = {
type = "server",
port = "${port}",
executable = {
command = vim.fn.stdpath("data") .. "/mason/packages/codelldb/codelldb",
args = { "--port", "${port}" },
detached = true,
},
}
dap.configurations.rust = {
{
name = "LLDB: Launch Binary",
type = "codelldb",
request = "launch",
program = function()
return vim.fn.input("Path to binary: ", vim.fn.getcwd() .. "/target/debug/", "file")
end,
cwd = "${workspaceFolder}",
stopOnEntry = false,
args = {},
runInTerminal = false,
},
{
name = "LLDB: Launch Target (Cargo)",
type = "codelldb",
request = "launch",
program = "${workspaceFolder}/target/debug/${workspaceFolderBasename}",
cwd = "${workspaceFolder}",
stopOnEntry = false,
args = {},
},
}
dap.configurations.c = dap.configurations.rust
dap.configurations.cpp = dap.configurations.rust
Using the nvim-dap-ui Interface
Once nvim-dap-ui is set up, a debugging session opens a multi-panel layout showing:
- Scopes — local, closure, and global variables at the current frame
- Breakpoints — all breakpoints with their status and conditions
- Stacks — call stack with frame selection
- Watch — custom expressions evaluated on every step
- REPL — interactive debug console
You can customize the layout by modifying the setup call:
require("dapui").setup({
layouts = {
{
elements = {
{ id = "scopes", size = 0.25 },
{ id = "breakpoints", size = 0.25 },
{ id = "stacks", size = 0.25 },
{ id = "watches", size = 0.25 },
},
size = 40,
position = "left",
},
{
elements = {
{ id = "repl", size = 0.5 },
{ id = "console", size = 0.5 },
},
size = 10,
position = "bottom",
},
},
floating = {
max_width = 120,
max_height = 30,
border = "rounded",
},
controls = {
enabled = true,
element = "repl",
icons = {
pause = "⏸",
play = "▶",
step_into = "⏭",
step_over = "⏩",
step_out = "⏪",
terminate = "⏹",
},
},
})
Advanced Debugging Techniques
Conditional Breakpoints
Instead of stopping on every hit, you can set breakpoints that only trigger when a condition is true. Press <leader>dB (using the mapping above) and enter an expression like i > 10 && user.name == "admin". The expression is evaluated in the debug target's language context.
-- Set a conditional breakpoint programmatically
dap.set_breakpoint(nil, nil, "i >= 100 and status == 'error'")
Logpoints (Non-Breaking Breakpoints)
Logpoints let you inject logging into running code without stopping execution. Use <leader>dl and enter a message like User {user.id} reached checkout. Curly-brace expressions are evaluated and interpolated.
-- Programmatic logpoint
dap.set_breakpoint(nil, nil, "Request from IP: {req.remote_addr}")
Exception Breakpoints
You can configure nvim-dap to break on caught or uncaught exceptions. For Python with debugpy:
dap.configurations.python = {
{
name = "Python: Break on Uncaught",
type = "python",
request = "launch",
program = "${file}",
console = "integratedTerminal",
breakOnException = {
uncaught = true,
caught = false,
},
},
}
Watch Expressions
Add expressions to the watch panel via the DAP REPL or directly:
-- Open REPL and evaluate expressions
-- :lua require("dap").repl.open()
-- Then type: .watch myVariable.length
-- Or add a watch programmatically
local dap = require("dap")
dap.add_watch("response.status_code", function(value)
vim.notify("Status code changed to: " .. value)
end)
Debugging Remote Processes
Attaching to a remote process is straightforward. For Python with debugpy running remotely:
-- Remote Python attach
dap.configurations.python = {
{
name = "Python: Remote Attach",
type = "python",
request = "attach",
connect = {
port = 5678,
host = "10.0.1.25",
},
pathMappings = {
{
localRoot = "${workspaceFolder}/src",
remoteRoot = "/app/src",
},
},
},
}
For Node.js, start your process with --inspect=0.0.0.0:9229 and attach using the Node attach configuration shown earlier.
Custom Debugger Commands
You can build custom commands that combine multiple debug actions. This example creates a "restart with same config" command:
local dap = require("dap")
local last_config = nil
-- Override continue to remember the last configuration
local orig_continue = dap.continue
dap.continue = function(config)
if config then
last_config = config
end
orig_continue(config)
end
-- Custom restart command
vim.keymap.set("n", "<leader>dR", function()
if dap.session() then
dap.restart(last_config or dap.session().config)
end
end, { desc = "Restart last debug session" })
Integrating with Testing
Debugging tests is a common workflow. Here's how to set up a test runner with debugging support using nvim-dap:
-- Example: Debug a specific Python test
vim.keymap.set("n", "<leader>dt", function()
local dap = require("dap")
dap.run({
name = "Python: Debug Test at Cursor",
type = "python",
request = "launch",
module = "pytest",
args = {
"${file}::${cursor_line_test_name}",
"-v", "-s",
"--no-header",
},
console = "integratedTerminal",
})
end, { desc = "Debug test under cursor" })
Handling DAP Session Events
You can hook into session lifecycle events to trigger custom behavior:
-- ~/.config/nvim/lua/dap-config/hooks.lua
local dap = require("dap")
-- Save breakpoints to a project-local file when session ends
dap.listeners.after.event_terminated["save_breakpoints"] = function(session)
local breakpoints = session.breakpoints or {}
local file = vim.fn.getcwd() .. "/.nvim-breakpoints.json"
local json = vim.json.encode(breakpoints)
vim.fn.writefile({ json }, file)
end
-- Restore breakpoints when a session initializes
dap.listeners.before.event_initialized["restore_breakpoints"] = function()
local file = vim.fn.getcwd() .. "/.nvim-breakpoints.json"
if vim.fn.filereadable(file) == 1 then
local lines = vim.fn.readfile(file)
local breakpoints = vim.json.decode(lines[1] or "[]")
for _, bp in ipairs(breakpoints) do
dap.set_breakpoint(bp)
end
end
end
-- Highlight the current line differently during debugging
local ns_id = vim.api.nvim_create_namespace("dap_highlight")
dap.listeners.after.event_stopped["highlight_stopped"] = function()
local line = vim.fn.line(".")
vim.api.nvim_buf_set_extmark(0, ns_id, line - 1, 0, {
hl_group = "Debug",
line_hl_group = "DebugLine",
virt_text = { { "▶ DEBUG", "Debug" } },
virt_text_pos = "overlay",
})
end
dap.listeners.after.event_continued["clear_highlight"] = function()
vim.api.nvim_buf_clear_namespace(0, ns_id, 0, -1)
end
Best Practices for Neovim Debugging
- Use Mason for adapter management — It handles versioning, paths, and compatibility. Avoid manually downloading debug adapters unless you have specific version requirements.
-
Organize adapter configs by language — Keep each language's DAP config in a separate file under
lua/dap-config/and require them from your main config. This keeps things maintainable as you add more languages. -
Set up per-project debug configurations — Use
.nvim.luaor.nvim-dap.luafiles in project roots to override defaults with project-specific settings like environment variables, arguments, or remote ports. - Leverage logpoints over print statements — Logpoints let you inject logging without modifying source code or stopping execution. They're faster to set up and remove than temporary print statements.
- Use conditional breakpoints sparingly — Complex conditions evaluated on every breakpoint hit can slow down execution. For performance-critical loops, consider narrowing the breakpoint scope instead.
-
Keep debug adapter binaries up to date — Run
:MasonUpdateperiodically. Adapter updates often bring support for new language features and runtime versions. - Learn the REPL — The DAP REPL supports evaluating expressions, modifying variables, and sending custom commands to the adapter. It's your most powerful debugging tool.
- Close the debug UI when done — The nvim-dap-ui layout can become distracting. The auto-open/close hooks shown above handle this gracefully.
-
Test adapter connections before debugging — Use
:lua require("dap").adapters.pythonto inspect your adapter config. A misconfigured adapter path is the most common source of debugging failures.
Troubleshooting Common Issues
When debugging doesn't work, check these things in order:
-- 1. Verify the adapter executable exists and is runnable
-- :!ls ~/.local/share/nvim/mason/packages/debugpy/venv/bin/python
-- 2. Test adapter communication manually
-- For server-type adapters, check if the port is available
-- :!lsof -i :9229
-- 3. Check DAP logs
vim.keymap.set("n", "<leader>dL", function()
require("dap").set_log_level("TRACE")
vim.notify("DAP log level set to TRACE. Check :messages", vim.log.levels.INFO)
end, { desc = "Enable DAP trace logging" })
-- 4. Verify your working directory is correct
-- The ${workspaceFolder} variable resolves to the nearest VCS root or cwd
-- :lua print(vim.fn.getcwd())
-- 5. Ensure Mason installed the adapter correctly
-- :Mason opens the UI; check that your adapter is listed and installed
Complete Debugging Workflow Example
Here is a full end-to-end example of debugging a Python function. Suppose you have this file:
# app.py
def process_order(items, user_id):
total = sum(item["price"] * item["quantity"] for item in items)
discount = calculate_discount(user_id, total)
final = total - discount
return {"user": user_id, "total": final}
def calculate_discount(user_id, amount):
# Bug: discount should be 0.15, not 0.015
rate = 0.015 if amount > 100 else 0
return amount * rate
if __name__ == "__main__":
items = [
{"price": 50, "quantity": 2},
{"price": 30, "quantity": 1},
]
result = process_order(items, "user_42")
print(result)
Your debugging session would look like:
- Place your cursor on line 3 (
total = sum(...)) and press<leader>dbto toggle a breakpoint - Press
<F5>to launch the debugger using your "Python: Launch File" configuration - Execution pauses at the breakpoint. nvim-dap-ui opens showing scopes and variables
- Hover over
itemsand press<leader>dvto inspect it in a floating window - Press
<F2>(step over) to move to line 4 — nowtotalis computed, check its value in the scopes panel - Step into
calculate_discountwith<F1> - Inside the function, you see
rate = 0.015— the bug is visible immediately - Open the REPL with
<leader>drand evaluateamount * 0.15to confirm the correct calculation - Press
<F3>to step out, then<F5>to continue to completion - Fix the bug, toggle the breakpoint off, and re-run to verify
Conclusion
Neovim debugging via DAP transforms a terminal-based editor into a professional-grade debugging environment. The combination of nvim-dap for protocol handling, nvim-dap-ui for visual feedback, and Mason for adapter management gives you a keyboard-driven, language-agnostic debugging experience that rivals dedicated IDEs. By investing time in configuring adapters for your languages, setting up intuitive key mappings, and learning the REPL and logpoint features, you will catch bugs faster, understand unfamiliar code more deeply, and eliminate the friction of switching tools during development. The setup described here is extensible to any DAP-compatible debugger, making it a future-proof foundation for your debugging workflow in Neovim.