Running Python Scripts Outside QGIS Desktop

HomePyQGIS Fundamentals & Environment SetupVirtual 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.

Desktop launch versus a standalone headless launchWhen QGIS desktop starts, the application automatically sets QGIS_PREFIX_PATH, PATH and sys.path, imports the bindings, and opens the GUI event loop with iface ready. A standalone interpreter runs the same three variables manually before any qgis import, then constructs QgsApplication with the GUI disabled and calls initQgis to reach the full vector, raster and Processing engine headlessly, before shutting down with exitQgis.QGIS Desktop launchStandalone interpreter (this page)QGIS application startsApp auto-sets the 3 variablesQGIS_PREFIX_PATH · PATH · sys.pathimport qgisGUI window + iface readyinteractive desktopEverything above is automaticpython script.pyYou inject the same 3 variablesbefore any qgis import — order mattersimport qgisQgsApplication([], False)initQgis() — GUI disabledVector · Raster · Processingfull engine, no GUI overheadexitQgis()same vars

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, and os.environ from 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.

What is available with no desktop runningAvailable headless: layers, geometry, coordinate transforms, processing algorithms, map settings and render jobs, and the expression engine. Not available: the iface object, the map canvas, dialogs and message bars, map tools, and the layer tree view. A note records that anything importing from qgis.gui should be treated as suspect.The dividing line runs between qgis.core and qgis.guiworks headless✓ QgsVectorLayer, QgsRasterLayer✓ QgsGeometry, QgsCoordinateTransform✓ processing.run()✓ QgsMapSettings + render jobs✓ the expression enginenot available✗ iface — there is no application✗ QgsMapCanvas and map tools✗ dialogs and the message bar✗ the layer tree viewanything from qgis.gui is suspect

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.

Open a project, or build everything in codeOpening an existing project inherits its layers, styles and layouts, so a cartographer can change the map without touching the script. Building everything in code gives full control and no external dependency, but every styling decision has to be expressed as Python and maintained there.Who owns the map design — the script, or a project file?read.setFileName("site.qgz")site.qgzlayers + stylesa short scripta cartographer can change the mapwithout touching your codebuild it all in codea long scriptlayers + stylesno external filefully reproducible from sourceevery design change is a code change

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.

ConcernRequirementFailure symptom
Python minor versionMatch 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.minorTarget the installed release; newer classes raise AttributeError on older buildsAttributeError on a missing class or method
Architecture64-bit interpreter against 64-bit QGISCrash during QgsApplication init, not at import
macOS prefixPoint QGIS_PREFIX_PATH at Contents/MacOS, not the .app rootApp initializes but loads no providers
Linux headlessExport QT_QPA_PLATFORM=offscreenqt.qpa.xcb: could not connect to display
Path handlingUse os.path.join and os.pathsep, never hard-coded separatorsFileNotFoundError 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.

  1. Verify path resolution first. Print sys.executable and os.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 missing qgis module. The parent Debugging PyQGIS Scripts guide covers isolating this cleanly.
  2. Prefer qgis_process for CI/CD. QGIS 3.14+ ships a standalone CLI that auto-configures the environment, so you avoid manual path mapping entirely for algorithm runs:
    qgis_process run native:buffer -- INPUT=roads.shp DISTANCE=100 OUTPUT=buffered.gpkg
    
    To run custom Python this way, wrap your logic as a Processing algorithm. This is the most reliable fallback in a pipeline.
  3. Fall back to a Conda environment. When system paths are unstable or PATH cannot be modified, conda-forge resolves 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
    
  4. 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_PATH or a missed initQgis(). Add from osgeo import gdal; gdal.UseExceptions() so driver errors are raised instead of swallowed, and log with QgsApplication.messageLog().logMessage("init ok", "Standalone").
  5. Remove the PyPI qgis stub. The qgis package on PyPI is a documentation placeholder that shadows the real bindings. Run pip list | grep qgis and pip uninstall qgis if 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.