Configure VS Code for PyQGIS Development
VS Code does not know where QGIS is, and QGIS does not know VS Code exists. Bridging them takes three settings and one launch configuration — after which imports resolve, completion works, and a breakpoint set in the editor stops inside the running application. Getting it wrong produces the familiar experience of red squiggles under every qgis import while the code runs perfectly.
This recipe belongs to Setting Up PyCharm for QGIS, which covers the same ground for the other common editor. It covers selecting the right interpreter per platform, adding the QGIS Python paths so analysis resolves, launching a standalone script with the environment set, and attaching to a running QGIS for plugin work.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer installed, or a conda environment containing QGIS.
- VS Code with the Python extension. Pylance provides the analysis; the older Jedi backend also works but resolves the QGIS bindings less well.
- For debugging inside QGIS, the
debugpypackage importable by the QGIS Python.
Point at the right interpreter
Which Python to select depends on the platform, and the file to find is not always obvious.
On Linux with a package-managed QGIS, the system python3 already has the bindings on its path, so the system interpreter works. On Windows, QGIS ships its own: C:\Program Files\QGIS 3.34\apps\Python312\python.exe, reached through the python-qgis.bat wrapper that sets the environment first. On macOS, it is inside the application bundle at QGIS.app/Contents/MacOS/bin/python3.
{
"python.defaultInterpreterPath": "/usr/bin/python3",
"python.analysis.extraPaths": [
"/usr/share/qgis/python",
"/usr/share/qgis/python/plugins",
"${workspaceFolder}"
],
"python.analysis.typeCheckingMode": "basic"
}
Breakdown: extraPaths is what fixes the squiggles — it tells the language server where to find qgis and processing without affecting what actually runs. The plugins directory matters because processing lives there rather than in the main python directory, which is why import processing resolves for the runtime and not for the analyser until this is set. Including ${workspaceFolder} lets a plugin's own modules resolve when the plugin package is the workspace root. typeCheckingMode: "basic" is a good middle setting: it catches real mistakes without flagging every dynamically typed Qt call.
These belong in .vscode/settings.json inside the project rather than in user settings, so the paths travel with the repository and a colleague on a different platform can override only what differs.
Launch a standalone script
A script that initialises QGIS itself needs the environment set before Python starts.
{
"version": "0.2.0",
"configurations": [
{
"name": "PyQGIS: run script",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false,
"env": {
"QT_QPA_PLATFORM": "offscreen",
"PYTHONPATH": "/usr/share/qgis/python:/usr/share/qgis/python/plugins"
}
}
]
}
Breakdown: justMyCode: false is worth setting deliberately — with the default true, stepping into QGIS's own Python (the Processing framework, for instance) is impossible, and a good deal of debugging in this area is exactly that. QT_QPA_PLATFORM=offscreen lets the script run without a display, which is what you want for anything destined for a server. ${file} runs whatever is open; a fixed program path is better once the entry point settles.
Attach to a running QGIS
Plugin code runs inside the QGIS process, so the debugger has to attach rather than launch.
# paste into the QGIS Python console once per session
import debugpy
debugpy.configure(python="/usr/bin/python3")
debugpy.listen(("127.0.0.1", 5678))
print("waiting for the debugger to attach…")
Breakdown: listen() opens a port and returns immediately; QGIS stays responsive. debugpy.configure(python=...) is needed when the QGIS interpreter is not the one debugpy would pick for its helper process, which is the usual case on Windows and macOS. Calling listen() twice in one session raises, so wrap it in a guard if it goes into a plugin's startup rather than being pasted by hand.
{
"name": "PyQGIS: attach to QGIS",
"type": "debugpy",
"request": "attach",
"connect": { "host": "127.0.0.1", "port": 5678 },
"justMyCode": false,
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "${env:HOME}/.local/share/QGIS/QGIS3/profiles/default/python/plugins/my_plugin"
}
]
}
Breakdown: pathMappings is the setting that makes breakpoints actually bind. The editor knows the file as ~/dev/my_plugin/dialog.py while QGIS loaded it from the profile's plugins directory; without a mapping, the debugger attaches successfully and every breakpoint stays hollow. Where the plugin is developed in place — the profile directory symlinked to the repository — the mapping is unnecessary, and that arrangement is worth setting up for exactly this reason. More on the mechanics in debugging a QGIS plugin with debugpy.
Tasks, linting and the rest of the loop
Two more pieces turn a working setup into a comfortable one.
A task for packaging saves remembering the incantation, and VS Code will run it from the command palette or a keybinding.
{
"version": "2.0.0",
"tasks": [
{
"label": "package plugin",
"type": "shell",
"command": "python",
"args": ["-m", "zipfile", "-c", "dist/my_plugin.zip", "my_plugin/"],
"group": "build",
"problemMatcher": []
},
{
"label": "run tests",
"type": "shell",
"command": "pytest",
"args": ["-q", "tests/"],
"group": "test",
"options": {
"env": {
"QT_QPA_PLATFORM": "offscreen",
"PYTHONPATH": "/usr/share/qgis/python:/usr/share/qgis/python/plugins"
}
},
"problemMatcher": ["$python"]
}
]
}
Breakdown: Setting the environment on the task rather than globally keeps the terminal clean for everything else, and it is the same pair of variables the launch configuration needs — worth extracting into a shell profile if it appears a third time. problemMatcher: ["$python"] makes pytest failures clickable in the problems panel, which is a small thing that changes how often the tests get run. The packaging task is deliberately the same command CI will run, so a package that builds locally builds there.
For linting, ruff is the pragmatic choice, and one configuration line prevents most of the noise PyQGIS code generates:
[tool.ruff.lint]
ignore = ["N802", "N803"] # Qt method names are camelCase by convention
Breakdown: Qt's naming conventions collide with PEP 8, and a plugin overriding initGui, unload or processAlgorithm cannot rename them. Silencing those two rules project-wide is better than scattering noqa comments, and it leaves the rest of the naming checks doing useful work on your own code.
Reload without restarting
Plugin development is a loop of edit, reload, test, and restarting QGIS each time is intolerable.
Install the Plugin Reloader plugin from the official repository, set it to your plugin, and bind its action to a key. It unloads and re-imports the plugin package, which picks up edits to any module inside it. What it does not pick up is changes to compiled resources or to metadata.txt, and it can leave a stale module cached if the plugin imports something from outside its own package — see reloading a QGIS plugin without restarting for the details and the cases where a restart is genuinely required.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.16 LTR | 3.7 | Paths as described; debugpy works, ptvsd was the older name. |
| 3.22 LTR | 3.9 | Windows bundled Python moves to apps\Python39. |
| 3.28 LTR | 3.9 | Profile directory layout unchanged. |
| 3.34 LTR | 3.12 | Baseline for this page; Windows path is apps\Python312. |
| 3.40+ | 3.12 | Qt6 builds may require a matching PyQt stub package for completion. |
Troubleshooting
- Red squiggles under
import qgisbut the code runs.python.analysis.extraPathsis not set. It is separate from the interpreter. import processingnever resolves. Thepython/pluginsdirectory was not added;processingis not in the main python directory.- The script fails to import QGIS at runtime. The interpreter is wrong, or
PYTHONPATHis not set in the launch config. - The debugger attaches but breakpoints stay hollow.
pathMappingsis missing or wrong. Compare the paths QGIS reports for the loaded module. - Cannot step into QGIS's own Python.
justMyCodeistrue. Set itfalse. debugpy.listenraises on a second run. It can only be called once per process. Guard it, or restart QGIS.
Conclusion
Set the interpreter, the analysis paths and the debug target separately — they are three different settings that all present as "VS Code cannot find QGIS". Keep them in the workspace's .vscode/settings.json so they travel with the repository, set justMyCode to false, and map the paths when attaching to a plugin loaded from the profile directory.
Frequently Asked Questions
Do I need QGIS type stubs?
Not for basic completion, which works from the shipped .pyi files and the bindings themselves. Stubs help with strict type checking — see enabling PyQGIS autocompletion with type stubs.
Can I use the QGIS Python console instead? For quick experiments, yes, and it is often faster. The editor earns its place for anything with more than one file, and for breakpoints — see running and saving scripts in the QGIS Python editor.
Does this work with a remote QGIS in Docker?
Yes — debugpy.listen(("0.0.0.0", 5678)) inside the container, the port published, and pathMappings translating the container path to the host one. See running PyQGIS in a Docker container.
Should the plugin live in the profile directory or the repository? The repository, with a symlink from the profile directory pointing at it. That removes the path mapping, keeps version control clean, and means a reload picks up edits immediately.