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.
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.
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.
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 version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | debugpy works; use a version compatible with Python 3.9. |
| 3.28 LTR | 3.9 | Identical. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Identical; 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
debugpyin 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'srun(). ModuleNotFoundError: debugpyin the console. It was installed into a different Python. Install withsys.executable -m pipfrom 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.