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. Noqgisconda package needed. - B — conda-forge (
env): installqgisfrom 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
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:
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):
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: