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.

Three things that all look like "the interpreter"Selecting an interpreter decides what runs a script. Extra analysis paths decide what the language server can see for completion and error checking, and are configured separately. The debugger target is a third choice: a script launched by the editor, or the QGIS process already running.Configure all three, or two of them will look broken1 · interpreterwhat runs the codepython.defaultInterpreterthe QGIS Python,or a conda envwrong → imports fail at run2 · analysis pathswhat completion seespython.analysis.extraPathsthe QGIS python dirand plugins dirwrong → red squiggles only3 · debug targetwhat stops at a breakpointlaunch.jsonlaunch a script, orattach to QGISwrong → breakpoints ignoreda plugin needs the third; a standalone script needs the first two

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 debugpy package 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.

Attaching across two processesVS Code runs in one process and QGIS in another. The debugpy library inside QGIS listens on a local port, and the editor connects to it. Because the editor sees files in the repository and QGIS loaded them from the profile plugins directory, a path mapping is required for breakpoints to bind.Two processes, two views of the same fileVS Code~/dev/my_plugin/dialog.pybreakpoint set herelocalRootport 5678debugpyQGIS process…/profiles/default/python/plugins/my_plugin/dialog.pythe file actually executingremoteRootwithout pathMappings the breakpoint stays hollow and never binds

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 versionPythonNotes
3.16 LTR3.7Paths as described; debugpy works, ptvsd was the older name.
3.22 LTR3.9Windows bundled Python moves to apps\Python39.
3.28 LTR3.9Profile directory layout unchanged.
3.34 LTR3.12Baseline for this page; Windows path is apps\Python312.
3.40+3.12Qt6 builds may require a matching PyQt stub package for completion.

Troubleshooting

  • Red squiggles under import qgis but the code runs. python.analysis.extraPaths is not set. It is separate from the interpreter.
  • import processing never resolves. The python/plugins directory was not added; processing is not in the main python directory.
  • The script fails to import QGIS at runtime. The interpreter is wrong, or PYTHONPATH is not set in the launch config.
  • The debugger attaches but breakpoints stay hollow. pathMappings is missing or wrong. Compare the paths QGIS reports for the loaded module.
  • Cannot step into QGIS's own Python. justMyCode is true. Set it false.
  • debugpy.listen raises 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.