Running Python Scripts Outside QGIS Desktop
Home → PyQGIS Fundamentals & Environment Setup → Virtual Environments for QGIS and PyQGIS → Running Scripts Outside QGIS
Running PyQGIS from a plain terminal, a cron job, or a CI/CD runner means doing manually what the QGIS application does for you at startup: locating the bindings, wiring up their compiled C++ dependencies, and standing up an application context. To run a Python script outside QGIS desktop you must bootstrap the environment before importing any qgis module — set QGIS_PREFIX_PATH and PATH, extend sys.path, then call QgsApplication.initQgis() in headless mode. Done correctly, you keep the full spatial engine — vector and raster APIs, the GDAL/OGR drivers, and the Processing framework — with none of the GUI overhead.
This page is the general launch pattern for standalone execution: scheduled exports, server-side geoprocessing, batch pipelines, and automated tests. It builds directly on the isolation strategy covered in Virtual Environments for QGIS and PyQGIS, and shares its bootstrap block with Fixing PyQGIS Module Import Errors — the difference is emphasis. Here the goal is a reliable, repeatable headless launch rather than a one-off diagnosis.
Prerequisites
Before you run a standalone script, confirm the following so you are configuring the right layer of the stack:
- A working QGIS install, with its exact version noted. Check Help → About. Paths differ between a Windows Standalone build, OSGeo4W, a macOS bundle, and a Linux package.
- The bundled Python version. PyQGIS bindings are compiled against one specific Python ABI. QGIS 3.34 and later — including the 3.44 LTR — ship Python 3.12, while the older 3.28 LTR shipped Python 3.9. Your standalone interpreter must match this minor version.
- A 64-bit interpreter. Modern PyQGIS is strictly 64-bit; a 32-bit Python will fail to load
_core. - An isolated environment for reproducibility. For anything you intend to deploy, run inside a dedicated virtual environment or Conda environment rather than the system Python, so QGIS libraries resolve identically across development and production machines.
- The ability to print your environment. You should be able to inspect
sys.executable,sys.version, andos.environfrom the interpreter that runs the job — most standalone failures are a mismatched interpreter, not a code bug.
The Standalone Bootstrap Recipe
Inject the QGIS paths before any qgis import. Import order matters: the compiled libraries are resolved against PATH and sys.path at import time, so setting these variables afterwards has no effect. Place this block at the very top of your script.
import os
import sys
# 1. Set the QGIS installation root (adjust per OS and install type)
QGIS_PREFIX = r"C:\Program Files\QGIS 3.44\apps\qgis-ltr" # Windows Standalone LTR
# QGIS_PREFIX = r"C:\OSGeo4W\apps\qgis-ltr" # Windows OSGeo4W
# QGIS_PREFIX = "/Applications/QGIS.app/Contents/MacOS" # macOS bundle
# QGIS_PREFIX = "/usr" # Linux (Debian/Ubuntu)
# 2. Inject QGIS paths BEFORE importing qgis modules
sys.path.insert(0, os.path.join(QGIS_PREFIX, "python"))
sys.path.insert(0, os.path.join(QGIS_PREFIX, "python", "plugins"))
os.environ["QGIS_PREFIX_PATH"] = QGIS_PREFIX
os.environ["PATH"] = os.path.join(QGIS_PREFIX, "bin") + os.pathsep + os.environ.get("PATH", "")
from qgis.core import QgsApplication, QgsVectorLayer
# 3. Initialize a headless QGIS application (False disables the GUI)
QgsApplication.setPrefixPath(QGIS_PREFIX, True)
qgs = QgsApplication([], False)
qgs.initQgis()
# 4. Run your GIS logic
layer = QgsVectorLayer("data/roads.shp", "roads", "ogr")
if layer.isValid():
print(f"Loaded {layer.featureCount()} features")
else:
print("Layer failed to load. Check the path and OGR drivers.")
# 5. Clean shutdown
qgs.exitQgis()
The False in QgsApplication([], False) is the whole point of a standalone run: it disables GUI initialization, so no windows or display server are required. That is what makes the script safe for cron, servers, and headless runners. Note that PYTHONHOME is deliberately left unset — forcing it makes the interpreter look for its standard library inside the QGIS prefix, which breaks virtual environments and can crash silently. Setting QGIS_PREFIX_PATH, PATH, and sys.path is enough.
Adding the Processing framework
QgsVectorLayer, geometry, and CRS classes are available the moment initQgis() returns. The Processing framework's native algorithms are not — they live in a plugin that must be registered explicitly before processing.run() will resolve native:* algorithm IDs:
from qgis.analysis import QgsNativeAlgorithms
import processing
from processing.core.Processing import Processing
Processing.initialize()
QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms())
With the registry populated you can drive the same algorithms you use interactively — see Run a Processing Algorithm from a Script for the call-and-parameters pattern that this bootstrap unlocks.
Wiring it into a scheduler
For cron or Task Scheduler, do not rely on the shell's ambient environment. Wrap the call in a small launcher that exports the variables first, then invokes the interpreter, so the job runs identically whether a human or the scheduler triggers it:
#!/usr/bin/env bash
export QGIS_PREFIX_PATH=/usr
export QT_QPA_PLATFORM=offscreen # headless Linux: no X display
export PATH="$QGIS_PREFIX_PATH/bin:$PATH"
exec /usr/bin/python3 /opt/pipelines/standalone_script.py
On a headless Linux server the QT_QPA_PLATFORM=offscreen export is essential — even in GUI-disabled mode Qt may try to open an X connection and abort with qt.qpa.xcb: could not connect to display.
What is missing without the desktop
A standalone script has all of qgis.core and none of the application. Knowing which side of that line a class falls on prevents most of the failures.
Loading a project versus building one
Two shapes of standalone script exist, and choosing between them decides how much of the work lives in Python rather than in a .qgz file someone can edit without you.
QGIS-version compatibility notes
Standalone launches are far more sensitive to version drift than in-app scripting, because you are hard-coding paths and ABIs the application normally discovers for you.
| Concern | Requirement | Failure symptom |
|---|---|---|
| Python minor version | Match the QGIS-bundled version exactly (3.12 for 3.34+/3.44 LTR; 3.9 for 3.28 LTR) | ImportError: DLL load failed or ModuleNotFoundError: qgis |
| QGIS major.minor | Target the installed release; newer classes raise AttributeError on older builds | AttributeError on a missing class or method |
| Architecture | 64-bit interpreter against 64-bit QGIS | Crash during QgsApplication init, not at import |
| macOS prefix | Point QGIS_PREFIX_PATH at Contents/MacOS, not the .app root | App initializes but loads no providers |
| Linux headless | Export QT_QPA_PLATFORM=offscreen | qt.qpa.xcb: could not connect to display |
| Path handling | Use os.path.join and os.pathsep, never hard-coded separators | FileNotFoundError during setPrefixPath() |
Pin your script to the LTR you actually deploy against. Patch releases within a minor version (3.12.1 vs 3.12.4) are interchangeable; minor versions (3.11 vs 3.12) are not.
Troubleshooting
If initialization fails or the process exits with no output, work through these in order.
- Verify path resolution first. Print
sys.executableandos.environ["QGIS_PREFIX_PATH"]immediately after assignment. A mismatched interpreter — the terminal working while the scheduler or IDE runs a different Python — is the single most common cause of a missingqgismodule. The parent Debugging PyQGIS Scripts guide covers isolating this cleanly. - Prefer
qgis_processfor CI/CD. QGIS 3.14+ ships a standalone CLI that auto-configures the environment, so you avoid manual path mapping entirely for algorithm runs:
To run custom Python this way, wrap your logic as a Processing algorithm. This is the most reliable fallback in a pipeline.qgis_process run native:buffer -- INPUT=roads.shp DISTANCE=100 OUTPUT=buffered.gpkg - Fall back to a Conda environment. When system paths are unstable or
PATHcannot be modified,conda-forgeresolves GDAL, PROJ, Qt, and the SIP bindings for you:conda create -n qgis-standalone -c conda-forge qgis python=3.12 conda activate qgis-standalone python standalone_script.py - Surface silent provider failures. If a layer loads with zero features but no error appears, GDAL/OGR drivers were never registered — usually an incomplete
QGIS_PREFIX_PATHor a missedinitQgis(). Addfrom osgeo import gdal; gdal.UseExceptions()so driver errors are raised instead of swallowed, and log withQgsApplication.messageLog().logMessage("init ok", "Standalone"). - Remove the PyPI
qgisstub. Theqgispackage on PyPI is a documentation placeholder that shadows the real bindings. Runpip list | grep qgisandpip uninstall qgisif it appears.
Conclusion
Running PyQGIS outside QGIS desktop comes down to three moves: extend sys.path and set QGIS_PREFIX_PATH/PATH before any qgis import, construct QgsApplication([], False) and call initQgis(), then register the Processing provider only if you need native:* algorithms. Match your Python minor version to the QGIS build exactly, export QT_QPA_PLATFORM=offscreen on headless Linux, and clean up with exitQgis(). When manual path mapping stays fragile — locked-down runners, tangled schedulers — reach for qgis_process or a Conda environment instead of fighting PATH.
Frequently Asked Questions
Why must I set the environment variables before importing any qgis module?
Importing qgis.core triggers loading of compiled C++ libraries (Qt, GDAL, PROJ) that are resolved against PATH and QGIS_PREFIX_PATH at import time. If you set those variables after the import, the dynamic linker has already searched the wrong directories, producing ImportError: DLL load failed or silent provider failures.
What does the False argument in QgsApplication([], False) do?
The second argument controls GUI initialization. Passing False runs QGIS in headless mode, so no windows or display server are needed — that is what makes the script suitable for cron jobs, servers, and CI/CD runners.
Why does my standalone script crash on a Linux server with no display?
Even in headless mode, Qt may try to connect to an X display. Export QT_QPA_PLATFORM=offscreen before running so Qt uses its offscreen backend instead of attempting an X11 connection.
My layer loads with zero features but no error appears — what went wrong?
GDAL/OGR drivers were not registered, usually because QGIS_PREFIX_PATH is incomplete or initQgis() never ran. Verify the prefix path, confirm initQgis() executed, and call gdal.UseExceptions() so driver errors surface instead of failing silently.
When should I use qgis_process instead of bootstrapping QgsApplication myself?
Use qgis_process for CI/CD and scheduled tasks that mainly run existing Processing algorithms — it auto-configures the environment and avoids fragile path mapping. Bootstrap QgsApplication directly only when you need custom Python logic beyond a single algorithm call.
Related
- Virtual Environments for QGIS and PyQGIS — parent guide to isolating a standalone interpreter
- Fixing PyQGIS Module Import Errors — diagnose a broken
import qgisin the same bootstrap - Run a Processing Algorithm from a Script — drive
processing.run()once the environment is up - How to Install QGIS Python Bindings on Windows