background-shape
Debugging in Neovim with nvim-dap, Go and Python in 2024
April 17, 2024 · 7 min read · by Muhammad Amal programming

TL;DRnvim-dap is a Debug Adapter Protocol client. With nvim-dap-ui on top and language-specific adapters (delve for Go, debugpy for Python), you get breakpoints, step-through, watch expressions, and call stacks. Setup is more work than VS Code’s “Run and Debug” button, but it’s a one-time cost.

Debugging is where Neovim has historically been weakest. For years the answer was “use print statements” or “drop to a terminal and run dlv/pdb manually.” That’s still a fine answer for quick triage, but for anything more involved than a single function call, you want a real debugger inside the editor.

nvim-dap reached usable maturity around 2022 and is solid in 2024. It implements the Debug Adapter Protocol that Microsoft built for VS Code, which means almost any DAP adapter that works for VS Code works for Neovim. The ecosystem is mature for the languages I care about most, Go and Python.

This post walks through the working setup. I’ll cover Go and Python specifically because they’re what I use daily, but the patterns translate to any DAP-compatible language.

The Plugins

Four plugins, one config file:

-- lua/plugins/dap.lua
return {
  { 'mfussenegger/nvim-dap',
    dependencies = {
      'rcarriga/nvim-dap-ui',
      'theHamsta/nvim-dap-virtual-text',
      'nvim-neotest/nvim-nio',
    },
    keys = {
      { '<leader>db', function() require('dap').toggle_breakpoint() end },
      { '<leader>dc', function() require('dap').continue()          end },
      { '<leader>di', function() require('dap').step_into()         end },
      { '<leader>do', function() require('dap').step_over()         end },
      { '<leader>dO', function() require('dap').step_out()          end },
      { '<leader>dr', function() require('dap').repl.toggle()       end },
      { '<leader>du', function() require('dapui').toggle()          end },
    },
    config = function()
      local dap   = require('dap')
      local dapui = require('dapui')

      dapui.setup()
      require('nvim-dap-virtual-text').setup({ commented = true })

      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

      vim.fn.sign_define('DapBreakpoint', { text = '*', texthl = 'DiagnosticError' })
      vim.fn.sign_define('DapStopped',    { text = '->', texthl = 'DiagnosticWarn' })
    end },
}

The listeners hook DAP’s lifecycle events to open and close the UI automatically. Without them, you’d have to remember to :lua require('dapui').open() every time you start a session, which gets old fast.

nvim-dap-virtual-text is optional but worth it. It shows variable values inline as you step through code, which is the feature you’d reach for in VS Code’s “watch” panel but more ambient.

Go With Delve

Go debugging uses delve. Install via Mason (:MasonInstall delve) or go install github.com/go-delve/delve/cmd/dlv@latest.

The nicest wrapper for Go is nvim-dap-go, which sets up the adapter and adds Go-specific commands:

-- Add to dap.lua dependencies and config
{ 'leoluz/nvim-dap-go',
  ft = 'go',
  dependencies = { 'mfussenegger/nvim-dap' },
  config = function()
    require('dap-go').setup({
      delve = { detached = vim.fn.has('win32') == 0 },
    })
    vim.keymap.set('n', '<leader>dt', function() require('dap-go').debug_test() end)
    vim.keymap.set('n', '<leader>dT', function() require('dap-go').debug_last_test() end)
  end },

The killer feature here is debug_test(). Cursor on a Go test function, <leader>dt, and delve attaches to that specific test. Breakpoint anywhere, step through, inspect locals. This is the workflow I use most.

For debugging a main package, the equivalent of VS Code’s “Run and Debug”:

-- Manual launch config for go run
require('dap').configurations.go = {
  {
    type    = 'go',
    name    = 'Debug',
    request = 'launch',
    program = '${file}',
  },
  {
    type    = 'go',
    name    = 'Debug Package',
    request = 'launch',
    program = '${fileDirname}',
  },
  {
    type    = 'go',
    name    = 'Attach',
    mode    = 'local',
    request = 'attach',
    processId = require('dap.utils').pick_process,
  },
}

<leader>dc (continue) prompts you to pick a configuration if there isn’t an active session, and the configuration starts delve in the right mode.

Python With debugpy

Python uses debugpy. Install via Mason (:MasonInstall debugpy) or pip install debugpy in the project’s virtualenv.

-- Add to dap.lua
{ 'mfussenegger/nvim-dap-python',
  ft = 'python',
  dependencies = { 'mfussenegger/nvim-dap' },
  config = function()
    local mason_path = vim.fn.stdpath('data') .. '/mason/packages/debugpy/venv/bin/python'
    require('dap-python').setup(mason_path)

    vim.keymap.set('n', '<leader>dn', function() require('dap-python').test_method() end)
    vim.keymap.set('n', '<leader>df', function() require('dap-python').test_class()  end)
  end },

test_method() debugs the pytest test under your cursor. Same pattern as the Go integration.

The thorny bit with Python is virtualenvs. By default, dap-python uses the debugpy binary you point it at, but it needs to execute the project’s Python (with its dependencies). The cleanest way:

-- Detect project venv and override the python path
local function get_python_path()
  local venv = os.getenv('VIRTUAL_ENV')
  if venv then return venv .. '/bin/python' end
  local cwd = vim.fn.getcwd()
  for _, candidate in ipairs({ cwd .. '/.venv/bin/python', cwd .. '/venv/bin/python' }) do
    if vim.fn.executable(candidate) == 1 then return candidate end
  end
  return 'python'
end

require('dap-python').setup(get_python_path())

This checks for an active VIRTUAL_ENV, then for a .venv or venv directory in the project root. Most Python projects use one of those conventions.

The UI Layout That Works

nvim-dap-ui defaults are decent but I customize. The layout I land on for a 1920x1080 display:

require('dapui').setup({
  layouts = {
    {
      elements = {
        { id = 'scopes',      size = 0.30 },
        { id = 'breakpoints', size = 0.15 },
        { id = 'stacks',      size = 0.25 },
        { id = 'watches',     size = 0.30 },
      },
      size = 50,
      position = 'left',
    },
    {
      elements = { { id = 'repl', size = 0.5 }, { id = 'console', size = 0.5 } },
      size = 10,
      position = 'bottom',
    },
  },
})

The left sidebar holds the four panels you actually look at while debugging. The bottom holds the REPL (where you can type expressions to evaluate in the paused context) and the console (program stdout/stderr).

Hit <leader>du to toggle the UI on and off without ending the session. Useful when you want full-screen code temporarily.

I covered the broader workflow context in Tmux and Neovim together, running the debug session in a tmux window separate from the main editor is a pattern that scales when the project gets complex.

Real Workflows

A few patterns from actual debugging sessions.

Conditional breakpoints. When you only care about hits where some condition is true, require('dap').set_breakpoint(vim.fn.input('Condition: ')) sets a breakpoint that fires only when the expression evaluates to true. Useful when a function runs 10,000 times and you want the one call where x > 1000.

Logpoints. Instead of a breakpoint, log a value:

vim.keymap.set('n', '<leader>dl', function()
  require('dap').set_breakpoint(nil, nil, vim.fn.input('Log: '))
end)

This is the modern replacement for printf debugging, you set a logpoint that prints “x is {x}, y is {y}” without recompiling.

Watch expressions. In the REPL panel, type any expression. It evaluates in the current paused context. Doubles as a sanity check (“is this slice nil?”) without modifying the source.

Restart vs continue. <leader>dc continues until the next breakpoint. If you want to restart from the entry point with the same configuration, require('dap').restart() does it without reselecting the config.

Common Pitfalls

  • Forgetting to set the Python path. The most common dap-python failure is “module not found” because it’s running with debugpy’s bundled Python, not your project’s. The get_python_path() helper above fixes this.
  • Delve port conflicts. If a delve process didn’t clean up properly, the next session fails to bind to port 17500. Kill stray delve processes (pkill dlv) and retry.
  • Breakpoints not hitting in goroutines. Delve handles goroutines but the default config doesn’t always step into them cleanly. If you need this, set mode = 'debug' and verify the goroutine breakpoint with :lua require('dap').list_breakpoints().
  • Symbols stripped in compiled binaries. If you’re attaching to a running binary, it needs to be built with go build -gcflags="all=-N -l" to disable optimizations and inlining. Otherwise breakpoints land on weird lines.
  • REPL eating keys. When the REPL is focused, normal-mode keystrokes go to the REPL, not your editor. <C-w>w to switch panes back.

Wrapping Up

nvim-dap is real debugging, not a print-statement replacement. It needs more setup than VS Code’s “Run and Debug” button, but you do it once per language and forget about it.

For Go and Python specifically, the wrappers (nvim-dap-go, nvim-dap-python) handle most of the friction. Add nvim-dap-ui for the panels, nvim-dap-virtual-text for inline values, and you have a debugger that compares well with anything else.

The nvim-dap wiki has adapter configs for 30+ languages, and the DAP specification is worth skimming once if you want to write your own adapter or troubleshoot weird behavior.