← Back to DevBytes

Neovim Debugging: Complete Guide

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

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:

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:

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

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:

  1. Place your cursor on line 3 (total = sum(...)) and press <leader>db to toggle a breakpoint
  2. Press <F5> to launch the debugger using your "Python: Launch File" configuration
  3. Execution pauses at the breakpoint. nvim-dap-ui opens showing scopes and variables
  4. Hover over items and press <leader>dv to inspect it in a floating window
  5. Press <F2> (step over) to move to line 4 — now total is computed, check its value in the scopes panel
  6. Step into calculate_discount with <F1>
  7. Inside the function, you see rate = 0.015 — the bug is visible immediately
  8. Open the REPL with <leader>dr and evaluate amount * 0.15 to confirm the correct calculation
  9. Press <F3> to step out, then <F5> to continue to completion
  10. 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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles