Skip to content

How qgis-project connects to QGIS

QGIS has a powerful Python API (PyQGIS — qgis.core, qgis.gui, processing, …), but it is not available on PyPI. The bindings are compiled C++ extensions that live inside a QGIS installation and are tied to the exact Python version, Qt version, and GDAL/PROJ libraries that install was built against.

qgis-project itself is pure Python with no runtime dependencies; all the real work happens in PyQGIS. So before anything runs, you need a way to reach those bindings. There are three ways to do that. The package supports all three and picks one automatically (see Launch modes). This page explains each one in plain language — what it does, when to use it, and how it works under the hood.

The short version

Two of these are the recommended setups:

  • A — Subprocess wrapper (local): you already have the QGIS desktop app; keep your own lightweight Python env and let the package run the work through QGIS's bundled Python. No qgis conda package needed.
  • B — conda-forge (env): install qgis from conda-forge into a conda environment and run everything in that interpreter.

The third (manual in-process with a standalone install) is fragile and only for advanced cases.


The three approaches

A — Subprocess execution wrapper

Recommended if you already use the QGIS desktop app.   → Strategy 2, mode local

You install QGIS the normal way (the desktop app from qgis.org, or OSGeo4W on Windows). That install ships its own private Python with PyQGIS already wired up. Instead of importing PyQGIS into your interpreter, the package serializes a small description of your project to a temporary file and hands it to QGIS's bundled Python in a child process. On Windows and macOS this goes through the platform launcher (python-qgis.bat / python-qgis.sh). On Linux there is no such wrapper — distro packages (e.g. apt's qgis + python3-qgis) and self-builds expose PyQGIS to the system interpreter, so the package locates a python3 beside the qgis binary that can import qgis and runs the child process with it. (Sandboxed installs — snap, flatpak, AppImage — keep their bindings confined and can't be driven this way; use approach B instead.)

Why it's nice. Your own environment stays tiny — just qgis-project itself. You never install the heavy qgis conda package, and you never hit Python/Qt/GDAL version conflicts, because the code executes inside the exact environment QGIS shipped. One QGIS install on the machine is enough.

The trade-off. It is a little "hacky": data crosses a process boundary as a serialized spec, and because execution happens in a separate process there are no live QGIS objects to inspect — snapshot() and other live-canvas features are unavailable.

Ready-made env: environments/qgis-project-local.yml.

B — QGIS from conda-forge (in-process)

Recommended for interactive / notebook use.   → Strategy 3, mode env

You create a conda environment and install qgis from the conda-forge channel alongside your own packages. conda-forge builds the bindings against that environment's exact Python, so a plain import qgis just works. The package then runs everything in your interpreter, in-process, with full access to live QGIS objects.

Why it's nice. Clean and conventional — no subprocess, no launcher, no DLL juggling. Live objects are available.

The trade-off. The environment is large (qgis pulls in Qt, GDAL, PROJ, etc. — easily over 1 GB), and if you also have the desktop app you now keep two copies of the QGIS stack on disk. The conda-forge QGIS version is independent of any desktop install.

Ready-made env: environments/qgis-project-env.yml.

C — Manual in-process setup with a standalone install

Advanced and fragile — prefer A or B.   → Strategy 1, mode env

This reaches into an existing QGIS desktop install and imports its bindings directly into your interpreter — no subprocess, no conda qgis. The package replicates what the launcher does (puts the bindings on sys.path, registers DLL directories, and pre-loads the native libraries) so that import qgis succeeds in your process.

Why it's tempting. A single QGIS install (like A) but with live in-process objects (like B).

Why it's fragile. Your interpreter's Python version must match the standalone's bundled Python exactly — the bindings are ABI-locked, so a build for Python 3.9 cannot load under 3.12. On top of that, any conflicting package in your environment (a newer NumPy, a second Qt) can crash the bindings at import. This is the classic "dependency hell" path. When the Python versions don't match, the package detects it and automatically falls back to approach A if a launcher is available.

Ready-made env: environments/qgis-project-standalone.yml.


At a glance

A — Subprocess wrapper B — conda-forge C — Manual in-process
Strategy / mode 2 / local 3 / env 1 / env
Needs QGIS desktop install ✅ yes no ✅ yes
Needs qgis from conda no ✅ yes no
Your Python env tiny (no deps) large (QGIS stack) tiny, version-locked
Code runs in QGIS bundled Python (subprocess) your interpreter your interpreter
Live QGIS objects no ✅ yes ✅ yes
Main risk none (process-isolated) disk size, duplicate stacks version/ABI conflicts
Verdict ✅ recommended ✅ recommended ⚠️ advanced

Launch modes (forcing the choice)

By default qgis_project auto-picks: it uses the QGIS bindings importable in the current environment (in-process, approach B or C) when available, otherwise it falls back to a standalone install driven through the launcher (approach A). Override this with the QGIS_PROJECT_LAUNCH_MODE environment variable or at runtime via set_mode():

Value Meaning
auto Default — in-process if importable, else the standalone launcher.
env Force the QGIS bindings in the current Python env (in-process).
local Force a standalone/local QGIS install (subprocess launcher).
# Ignore an importable `qgis` and drive the local standalone install instead:
export QGIS_PROJECT_LAUNCH_MODE=local   # Windows: set QGIS_PROJECT_LAUNCH_MODE=local
import qgis_project
qgis_project.set_mode("local")      # same effect, at runtime, before Project()

Forcing a mode whose backend is unavailable raises immediately — set_mode("env") without importable bindings raises ImportError; set_mode("local") without a launcher raises RuntimeError. set_mode() must be called before constructing a Project.


Under the hood

The rest of this page covers the Windows-specific mechanics behind approaches A and C — useful if you are debugging an install, but not required reading.

Approach A — the platform launcher (python-qgis.bat)

QGIS ships a small launcher on each platform that sets up the environment and then runs the bundled Python interpreter. Approach A simply invokes it:

"C:\Program Files\QGIS 3.28.0\bin\python-qgis.bat" my_script.py

Open a terminal through /Applications/QGIS.app (e.g. via open -a QGIS and use the embedded Python) or use the OSGeo4Mac shell wrapper if installed.

python my_script.py   # system Python works if QGIS was installed system-wide

python-qgis.bat is a thin wrapper that chains several setup scripts and then runs the QGIS-bundled Python. The call sequence is:

python-qgis.bat
└── o4w_env.bat                       # sets OSGEO4W_ROOT, resets PATH, runs etc/ini/*.bat
    ├── qt5.bat                       # PATH += apps\qt5\bin; QT_PLUGIN_PATH
    ├── python3.bat                   # PYTHONHOME = apps\Python39; PATH += apps\Python39\Scripts
    └── gdal.bat / proj-runtime-data.bat / …

Then python-qgis.bat itself adds the final layer:

path %OSGEO4W_ROOT%\apps\qgis\bin;%PATH%
set QGIS_PREFIX_PATH=%OSGEO4W_ROOT:\=/%/apps/qgis
set GDAL_FILENAME_IS_UTF8=YES
set VSI_CACHE=TRUE
set VSI_CACHE_SIZE=1000000
set QT_PLUGIN_PATH=%OSGEO4W_ROOT%\apps\qgis\qtplugins;%OSGEO4W_ROOT%\apps\qt5\plugins
set PYTHONPATH=%OSGEO4W_ROOT%\apps\qgis\python;%PYTHONPATH%
python %*

The net result — directories on PATH and the Python environment that is launched:

What Value (QGIS 3.28 default)
PATH additions …\bin, …\apps\qt5\bin, …\apps\qgis\bin
PYTHONPATH …\apps\qgis\python
PYTHONHOME …\apps\Python39
QGIS_PREFIX_PATH .../apps/qgis
QT_PLUGIN_PATH …\apps\qgis\qtplugins;…\apps\qt5\plugins
GDAL_FILENAME_IS_UTF8 YES
VSI_CACHE / VSI_CACHE_SIZE TRUE / 1000000
Python interpreter …\bin\python.exe (QGIS-bundled, Python 3.9 for QGIS 3.28)

Approach C — self-configuring in-process setup

setup_qgis_env() (in qgis_project/_env.py) replicates the launcher's work from inside Python, so the bindings can be imported by an external interpreter — no wrapper needed. It runs automatically the first time you construct a Project in env mode. The table below shows how it maps to the bat, step by step (Windows):

Step python-qgis.bat (approach A) setup_qgis_env() (approach C)
Find install root OSGEO4W_ROOT set by o4w_env.bat from the bat's own location find_qgis_prefix_path() searches QGIS_PREFIX_PATH env var → CONDA_PREFIX (validated) → Program Files\QGIS*
Python bindings on path PYTHONPATH env var (effective for the child process) sys.path.insert(0, prefix/python) (modifies the running interpreter)
Qt5 DLLs apps\qt5\bin prepended to PATH before the process starts os.add_dll_directory(root/apps/Qt5/bin) + ctypes pre-load
QGIS DLLs apps\qgis\bin prepended to PATH before the process starts os.add_dll_directory(prefix/bin) + ctypes pre-load
GDAL DLLs bin\ on PATH (set by o4w_env.bat) os.add_dll_directory(root/bin) + ctypes pre-load of gdal*.dll
PYTHONHOME Set to apps\Python39 — tells the bundled Python where its stdlib is Not set — the external Python already knows its own stdlib
Env vars Set by python-qgis.bat for the child process os.environ.setdefault(...) on the running process

Why PYTHONHOME is intentionally skipped

The bat sets PYTHONHOME because it is launching the QGIS-bundled Python, which needs to be told where its standard library lives. When running with an external interpreter (conda, system Python), PYTHONHOME is already correct for that interpreter. Setting it to the QGIS Python path would break stdlib imports in your own code.

Why DLLs must be pre-loaded (Windows)

os.add_dll_directory() enlarges the search path used by Python's import machinery when it loads a .pyd file. It does not reliably affect LoadLibrary calls made from within that .pyd file — i.e. transitive dependencies.

_core.pyd links against qgis_core.dll, which links against Qt5 and GDAL DLLs. Because those loads happen inside the C extension (not through Python), add_dll_directory alone is insufficient. Pre-loading the DLLs with ctypes.CDLL puts them into the Windows process's loaded-module table so any subsequent LoadLibrary for the same name gets the already-loaded handle — no filesystem search needed.

Load order matters (dependencies before dependents):

Qt5Core.dll  →  gdal*.dll  →  qgis_core.dll  →  qgis/_core.pyd

Platform summary

Platform Env mechanism DLL/SO mechanism setup_qgis_env() helper
Windows PATH + PYTHONPATH via bat os.add_dll_directory + ctypes pre-load _setup_windows()
macOS Shell wrapper / QGIS.app env DYLD_LIBRARY_PATH _setup_macos()
Linux Shell wrapper or system install LD_LIBRARY_PATH _setup_linux()

Python version constraint (approach C)

The QGIS Python bindings (.pyd / .so files) are compiled for a specific CPython version. A standalone QGIS 3.28 installer bundles Python 3.9; those bindings cannot be loaded by Python 3.10 or any other version.

setup_qgis_env() detects this mismatch early. Rather than crash, it logs a warning and skips the in-process path so the package can fall back to the subprocess launcher (approach A) if one is available:

Python version mismatch: QGIS bundles Python39 but you are running Python310.
In-process QGIS (Strategy 1) will not be available; falling back to subprocess
launcher (Strategy 2) if found.

To avoid this constraint entirely, install QGIS via conda-forge (approach B) — conda always installs bindings compiled for that environment's Python version:

conda create -n qgis python=3.12 qgis -c conda-forge