Debug a QGIS Plugin with debugpy

Print statements work until the thing you need to inspect is a QgsFeature inside a signal handler that fires forty times. Then what you want is a breakpoint: execution stopped, every variable visible, and the ability to step one line at a time. Getting that inside a running QGIS takes about ten minutes to set up once, and it changes how plugin development feels.

This recipe belongs to Setting Up PyCharm for QGIS. It covers installing debugpy into the QGIS Python environment, starting a listener from the console, attaching from VS Code or PyCharm, mapping paths correctly, and debugging code that only runs in response to a click.

How the debugger reaches into a running QGISQGIS runs with debugpy loaded, listening on a local port. The editor connects to that port as a client. Breakpoints set in the editor are sent into the running process, and when one is hit, execution pauses and the call stack and variables are sent back to the editor. QGIS keeps running the whole time; nothing is restarted.One process, paused on demand — no restart, no reloadQGIS desktopyour plugin codedebugpy listening on 5678started from the consoleyour editorbreakpoints in the same filesstack, variables, step controlsattaches to the portbreakpoints instate back outThe files on both sides must be the same files — path mapping is where this usually goes wrong

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • VS Code, PyCharm Professional, or any editor supporting the Debug Adapter Protocol.
  • Permission to install a package into the QGIS Python environment — see Install Python Packages into the QGIS Environment if that is not straightforward on your platform.

Install debugpy into the QGIS Python

The debugger must live in the Python interpreter QGIS is using, not in a separate virtual environment. Install it from the QGIS console:

import subprocess
import sys

subprocess.check_call([sys.executable, "-m", "pip", "install", "--user", "debugpy"])

Breakdown: sys.executable inside the QGIS console is the QGIS Python interpreter, so this installs into the right environment regardless of what else is on the system — considerably more reliable than guessing the path. The --user flag avoids needing administrator rights and keeps the package out of the QGIS installation folder, which matters on Windows where that folder is often read-only and gets replaced on upgrade. On Linux, where QGIS commonly uses the system Python, the distribution's python3-debugpy package is an equally good route. Restart QGIS afterwards so the new package is importable.

Start listening from the QGIS console

import debugpy

debugpy.configure(python=r"C:/OSGeo4W/apps/Python312/python.exe")   # Windows only
debugpy.listen(("127.0.0.1", 5678))
print("waiting for the debugger to attach")

Breakdown: listen() opens a port and returns immediately, so QGIS stays usable while you attach — this is the important difference from wait_for_client(), which blocks the whole application until the editor connects. Binding to 127.0.0.1 rather than 0.0.0.0 keeps the debug port off the network, which matters because a debug port is remote code execution by design. The configure() call is needed only on Windows, where debugpy otherwise cannot find an interpreter to launch its adapter with. Run this once per QGIS session; calling listen() twice raises.

Where a bug happens during plugin loading, you do need to block:

debugpy.listen(("127.0.0.1", 5678))
debugpy.wait_for_client()          # QGIS freezes here until the editor attaches

Breakdown: Put these two lines at the top of the plugin's __init__.py, attach from the editor, and QGIS resumes with the debugger already in place — the only way to catch an exception thrown before you could have run anything in the console. Remove them before shipping, or every user's QGIS will hang on startup.

Attach from the editor

VS Code needs a launch configuration:

{
  "name": "Attach to QGIS",
  "type": "debugpy",
  "request": "attach",
  "connect": { "host": "127.0.0.1", "port": 5678 },
  "pathMappings": [
    {
      "localRoot": "${workspaceFolder}",
      "remoteRoot": "/home/ana/.local/share/QGIS/QGIS3/profiles/default/python/plugins/parcel_tools"
    }
  ],
  "justMyCode": true
}

Breakdown: The path mapping is the part that everybody gets wrong first. The debugger reports file paths as the running process sees them, and your editor has the files open from wherever you edit them; unless the two are connected, breakpoints show as unverified hollow circles and never fire. If you develop directly in the plugins folder — or symlink your repository into it, which is the better arrangement — localRoot and remoteRoot are the same and the mapping is trivial. justMyCode set to true stops the debugger diving into QGIS's own Python on every exception, which is almost always what you want.

PyCharm Professional uses a Python Debug Server run configuration on the same port, with the same path-mapping requirement, and its own pydevd-pycharm package instead of debugpy — the concepts are identical.

Why the breakpoint never turns solidWith a correct mapping, the file open in the editor and the file loaded by QGIS resolve to the same path, so the breakpoint binds and execution stops. With a mismatch, the editor has a copy in a repository folder while QGIS loaded a different copy from the plugins folder, so the breakpoint is never matched to any loaded code and remains unverified.A hollow breakpoint means the paths do not agreesame file, mappededitor: repo/parcel tools/tool.pyQGIS: plugins/parcel tools/tool.pymapping connects them — breakpoint bindstwo copies, no mappingeditor: repo/parcel tools/tool.pyQGIS: a copy pasted last weekbreakpoint stays hollow, and so does your afternoon

Break in code that only runs on a click

Once attached, set a breakpoint in the plugin method that handles the action and trigger it in QGIS — click the toolbar button, run the tool, use the map tool. Execution stops on the line, the editor shows the call stack, and you can inspect every local variable including live QGIS objects.

def run(self):
    layer = self.iface.activeLayer()
    request = QgsFeatureRequest().setFilterExpression("area_m2 > 5000")
    for feature in layer.getFeatures(request):        # breakpoint here
        self.process(feature)

Breakdown: With execution paused, the debug console evaluates arbitrary expressions in that frame — feature["ref"], feature.geometry().area(), layer.crs().authid() — which is faster than any amount of printing and works on objects that have no useful string representation. Stepping into self.process() shows what actually happens to each feature. One caveat specific to QGIS: while execution is paused, the application is frozen, so the canvas does not repaint and the interface looks hung. That is expected; it resumes when you continue.

Print statements against breakpointsDebugging with print statements requires editing the code, reloading the plugin and re-running the action for every value you want to see, so each question costs a full cycle. With a breakpoint the execution is paused once and any expression can be evaluated in that frame, so ten questions cost one cycle.Ten questions, one pauseprinteditreloadre-run the actionread one valuerepeat for every questionbreakpointrun the action oncepaused — evaluate anything in that frame, as often as you likethe setup costs ten minutes and repays them in the first session

Code running on a background task through QgsTask needs one extra line, because the debugger does not automatically trace threads it did not start:

debugpy.debug_this_thread()

Breakdown: Call it as the first line of the task's run() method and breakpoints inside it start working. Without it, a breakpoint in a background task is simply ignored, which looks identical to the path-mapping failure and is a good second thing to check. The threading model this fits into is described in Run a Background Task with QgsTask in PyQGIS.

Keep the debug hooks out of the shipped plugin

A convenient pattern is to gate the listener behind an environment variable so the code can stay in the repository without ever running for a user:

import os

if os.environ.get("QGIS_DEBUGPY") == "1":
    import debugpy
    debugpy.listen(("127.0.0.1", 5678))

Breakdown: Setting QGIS_DEBUGPY=1 before launching QGIS enables the listener; without it the import never happens, so a user who does not have debugpy installed is unaffected. This keeps the setup reproducible for other developers — they set one variable rather than re-deriving the whole configuration — and eliminates the recurring risk of shipping a wait_for_client() that hangs somebody's QGIS on startup. Whatever you do, make the packaging step in Package a QGIS Plugin as a Zip check for stray debug imports.

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9debugpy works; use a version compatible with Python 3.9.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical; nothing in the attach mechanism is QGIS-version specific.

What does vary is where the QGIS Python lives and whether you can write to it. On Windows use the OSGeo4W shell, on macOS the interpreter inside the application bundle, and on Linux usually the system Python — all reachable through sys.executable from the console.

Troubleshooting

  • Breakpoints stay hollow. Path mapping. Confirm with import parcel_tools; print(parcel_tools.__file__) in the console and map that folder.
  • Address already in use. A listener from a previous session is still bound. Restart QGIS, or use a different port.
  • The editor connects and immediately disconnects. Version mismatch between the debugpy in QGIS and the one in the editor's extension. Update both.
  • QGIS freezes on startup. A wait_for_client() left in the plugin. Remove it, or gate it behind an environment variable.
  • Breakpoints in a background task never fire. Add debugpy.debug_this_thread() at the start of the task's run().
  • ModuleNotFoundError: debugpy in the console. It was installed into a different Python. Install with sys.executable -m pip from inside QGIS.

Conclusion

Install debugpy into the QGIS interpreter with sys.executable -m pip, call listen() from the console, and attach from your editor with a path mapping that matches where QGIS actually loaded the plugin from. Breakpoints then work in click handlers, map tools and — with one extra line — background tasks. Gate the hook behind an environment variable so the same code is safe to commit and impossible to ship by accident.

Frequently Asked Questions

Does this work with PyCharm Community? No — remote debugging is a Professional feature. Use VS Code with debugpy, which is free and works identically for this purpose.

Can I debug a Processing algorithm? Yes. Attach as usual and set a breakpoint in processAlgorithm(). If it runs on a background thread, add debugpy.debug_this_thread() as the first line.

Is the debug port a security risk? Yes, if exposed. Bind to 127.0.0.1 only, and never leave a listener enabled on a shared or server machine.

Why is QGIS unresponsive while stopped at a breakpoint? Because the main thread is paused, and that thread draws the interface. It is not a crash; continue and it recovers.

Can two people attach at once? No. One client per listener. For pair debugging, share a screen.

Does attaching slow QGIS down? Slightly, while attached, and imperceptibly for most work. Detach when you are benchmarking anything — see Profile Slow PyQGIS Code.