Initializing QgsApplication in a Headless Environment

Correctly bootstrap QgsApplication with no display server — setPrefixPath, the offscreen Qt platform, PROJ/GDAL data paths, and clean initQgis/exitQgis…

TL;DR: Set QT_QPA_PLATFORM=offscreen, call QgsApplication.setPrefixPath("/usr", True), construct QgsApplication([], False), call initQgis(), keep that instance referenced for the whole script, then call exitQgis() at the end.

This page is part of the Standalone PyQGIS Scripts and Headless Execution guide, which sits inside the broader Headless Automation, CI/CD & Testing for PyQGIS reference. Everything else in that section — running a Processing algorithm headlessly, building a container, wiring up CI — assumes a QGIS runtime that has already been bootstrapped correctly. Get this bootstrap wrong and you get silent empty layers, isValid() returning False on perfectly good data, or a hard segfault on interpreter exit. Get it right once, factor it into a helper, and reuse it everywhere.

Complete Runnable Script

Drop this in as qgis_headless_bootstrap.py. It sets the environment before importing qgis.core, auto-detects a reasonable prefix path, initialises the runtime, runs a provider sanity check, and tears down cleanly. It requires no display server, no QGIS Desktop session, and no project file.

python
"""
qgis_headless_bootstrap.py

Minimal, self-contained bootstrap for a standalone PyQGIS runtime.
Runs with no display server (offscreen Qt platform). Compatible with
QGIS 3.28+ LTR.

Usage (standalone, no QGIS Desktop):
    python qgis_headless_bootstrap.py
"""
from __future__ import annotations

import os
import sys
from typing import Optional

# ---------------------------------------------------------------------------
# 1. Configure the environment BEFORE importing qgis.core / Qt.
#    QT_QPA_PLATFORM must be set before the Qt platform plugin is resolved,
#    which happens the first time a QGIS/Qt module is imported.
# ---------------------------------------------------------------------------
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

# Optional: pin PROJ/GDAL data directories when the defaults are incomplete
# (common in slim containers). Leave unset if your packaging is complete.
# os.environ.setdefault("PROJ_LIB", "/usr/share/proj")
# os.environ.setdefault("GDAL_DATA", "/usr/share/gdal")

from qgis.core import (  # noqa: E402  (import must follow env setup)
    Qgis,
    QgsApplication,
    QgsProviderRegistry,
)


def detect_prefix_path() -> str:
    """
    Return the QGIS install prefix.

    Honours a QGIS_PREFIX_PATH override, then falls back to the common
    system-package location. The prefix is the directory *above* the
    'share/qgis' and 'lib/qgis' trees, e.g. '/usr' for Debian packages
    or the '<install>/apps/qgis' folder on OSGeo4W.
    """
    override: Optional[str] = os.environ.get("QGIS_PREFIX_PATH")
    if override:
        return override
    for candidate in ("/usr", "/usr/local", "/opt/qgis"):
        if os.path.isdir(os.path.join(candidate, "share", "qgis")):
            return candidate
    return "/usr"


def init_headless_qgis() -> QgsApplication:
    """
    Bootstrap and return an initialised, headless QgsApplication.

    The returned object MUST stay referenced for the lifetime of the
    process. Call exitQgis() on shutdown (see teardown_qgis()).
    """
    prefix: str = detect_prefix_path()
    QgsApplication.setPrefixPath(prefix, True)

    # GUImode=False: no application windows, cursors, or event-loop widgets.
    qgs = QgsApplication([], False)
    qgs.initQgis()
    return qgs


def sanity_check() -> list[str]:
    """
    List the registered data providers and assert the core ones loaded.

    A healthy headless runtime always exposes at least 'ogr' and 'gdal';
    their absence means initQgis() did not find the provider plugins,
    usually a wrong prefix path.
    """
    providers: list[str] = QgsProviderRegistry.instance().providerList()
    missing = {"ogr", "gdal"} - set(providers)
    if missing:
        raise RuntimeError(
            f"Core providers missing: {sorted(missing)}. "
            "Check setPrefixPath() and the QGIS installation."
        )
    return providers


def teardown_qgis(qgs: QgsApplication) -> None:
    """Flush provider connections and release C++ resources."""
    qgs.exitQgis()


def main() -> int:
    qgs = init_headless_qgis()
    try:
        print(f"QGIS runtime : {Qgis.QGIS_VERSION} ({Qgis.QGIS_VERSION_INT})")
        print(f"Prefix path  : {QgsApplication.prefixPath()}")
        print(f"Qt platform  : {os.environ.get('QT_QPA_PLATFORM')}")
        providers = sanity_check()
        print(f"Providers    : {', '.join(sorted(providers))}")
        print("Headless QGIS initialised successfully.")
        return 0
    finally:
        teardown_qgis(qgs)


if __name__ == "__main__":
    sys.exit(main())

Run it and you should see the QGIS version, the resolved prefix, offscreen as the platform, and a provider list containing ogr and gdal. That output is your proof that the runtime is alive and every subsystem you care about resolved its resources.

The Bootstrap Lifecycle

The five moving parts below have to happen in order. Environment variables must be set before the first qgis.core import; the prefix path must be set before initQgis(); and exitQgis() must be the last thing that touches the runtime. The diagram traces that path.

Headless QgsApplication Initialisation Lifecycle A left-to-right pipeline: environment variables and prefix path feed into constructing QgsApplication, which is initialised with initQgis, after which the provider registry is ready for work, and finally exitQgis tears the runtime down. Environment QT_QPA_PLATFORM PROJ_LIB / GDAL_DATA setPrefixPath ("/usr", True) QgsApplication ([], False) initQgis() load registries Providers ready ogr, gdal, ... work done exitQgis()

Architecture Breakdown

setPrefixPath — where QGIS resolves its resources

QgsApplication.setPrefixPath(prefix, useDefaultPaths) tells QGIS the root of its installation tree. From that single directory, QGIS derives the location of the provider plugins (lib/qgis/plugins), the coordinate-system and SRS database (share/qgis/resources/srs.db), the Processing framework, the SVG symbol library, and the contributed C++ plugins. Passing useDefaultPaths=True makes QGIS compute all of those subpaths relative to the prefix using the compiled-in layout — which is exactly what you want on a standard install.

The correct value is packaging-dependent. On Debian/Ubuntu system packages the prefix is /usr; on conda-forge it is the environment root ($CONDA_PREFIX); on OSGeo4W it is the apps/qgis (or apps/qgis-ltr) directory under the install root. If the prefix is wrong, initQgis() succeeds but the provider registry comes up nearly empty, so a layer that should load returns isValid() == False for no obvious reason. The detect_prefix_path() helper above honours a QGIS_PREFIX_PATH override first, which is the cleanest way to make the same script portable across those channels — set the variable in your Dockerfile or CI job and leave the code untouched.

You can confirm the resolved value at runtime with QgsApplication.prefixPath() and inspect the derived locations with QgsApplication.pkgDataPath() and QgsApplication.pluginPath().

QT_QPA_PLATFORM=offscreen — the platform plugin

Qt requires a platform plugin to start, even when you never open a window. On a desktop that plugin is xcb (X11) or wayland, and it opens a connection to the display server. On a headless server or CI runner there is no display server, so the default plugin aborts with qt.qpa.plugin: Could not load the Qt platform plugin "xcb" before your code runs.

Setting QT_QPA_PLATFORM=offscreen selects the offscreen platform plugin, which satisfies Qt’s requirement with a null rendering backend and no display connection at all. This is enough for the overwhelming majority of automation: vector and raster I/O, coordinate transforms, geometry operations, attribute processing, and most Processing algorithms never touch a real widget. The variable must be exported before the first qgis.core/PyQt import, because the plugin is resolved once at import time — hence os.environ.setdefault(...) at the very top of the script, above the imports.

The offscreen platform does have a ceiling. A handful of code paths still construct genuine GUI classes or render through the map canvas — some print-layout export and symbol-rasterisation paths among them — and those can misbehave or crash under offscreen. When you hit that, the fix is a real (virtual) framebuffer: run under Xvfb. That is a container concern covered in Running Headless QGIS with Xvfb in Docker. Reach for Xvfb only when offscreen provably fails; it is heavier and slower to start.

PROJ_LIB and GDAL_DATA — the data directories

PROJ resolves CRS definitions, datum-shift grids, and the EPSG database from its data directory; GDAL resolves format support files, spatial-reference lookups, and driver metadata from GDAL_DATA. On a complete QGIS install these are discovered automatically and you should leave both variables unset. In slim containers, custom builds, or unusual conda layouts the discovery can fail, and then CRS construction quietly returns invalid objects while GDAL emits ERROR 4 messages — failures that look like data problems but are really path problems.

Only set these when the defaults are demonstrably wrong, and set them to real directories:

python
import os

# Pin only when auto-discovery fails; wrong values are worse than unset.
for var, path in (("PROJ_LIB", "/usr/share/proj"), ("GDAL_DATA", "/usr/share/gdal")):
    if not os.path.isdir(os.environ.get(var, "")):
        if os.path.isdir(path):
            os.environ[var] = path

Deeper CRS-path diagnostics — verifying grids are present and transforms resolve — are covered in the companion reference on troubleshooting CRS mismatches in PyQGIS scripts.

Keeping the QgsApplication reference alive

QgsApplication is a Python wrapper over a C++ QgsApplication/QApplication object, and the C++ instance’s lifetime is bound to the wrapper. Write this and you have a bug:

python
# WRONG: the object is created, initialised, then immediately eligible
# for garbage collection because nothing references it.
QgsApplication([], False).initQgis()   # do not do this

Once the wrapper is collected, the underlying C++ singleton can be destroyed, and the next call into the provider registry or a layer either returns empty results or segfaults. Always bind the instance to a name that outlives every QGIS call:

python
qgs = QgsApplication([], False)   # keep 'qgs' in scope until exitQgis()
qgs.initQgis()

In a script, a module-level or main()-local variable is fine. In a longer-lived service, store it on an object whose lifetime you control, and call exitQgis() exactly once on shutdown. exitQgis() flushes provider connections and releases resources; skipping it can leave file locks or database connections dangling, and calling it twice on the same instance is an error.

Best Practices

  • Set QT_QPA_PLATFORM=offscreen before importing qgis.core. The platform plugin is resolved at import time, so exporting it later has no effect. Prefer os.environ.setdefault so an explicit outer setting (e.g. an Xvfb job forcing xcb) still wins.
  • Make the prefix overridable. Read QGIS_PREFIX_PATH first and only fall back to a hard-coded default. This one change makes the same script run unmodified across Debian packages, conda-forge, and OSGeo4W.
  • Run a provider sanity check right after initQgis(). Asserting that ogr and gdal are in QgsProviderRegistry.instance().providerList() turns a silent wrong-prefix failure into an immediate, legible error.
  • Do not set PROJ_LIB/GDAL_DATA unless discovery fails. A wrong path is worse than no path. When you must set them, validate the directory exists first.
  • Always pair initQgis() with exitQgis(), and put the teardown in a finally block so it runs even when the work in between raises.
  • Guard version-specific API with Qgis.QGIS_VERSION_INT. Behaviour differs across 3.x point releases; branch on the integer version rather than parsing the version string.
  • Bind the QgsApplication to a durable name. Never chain QgsApplication([], False).initQgis() on one throwaway line.
  • Factor the bootstrap into one reusable helper and import it everywhere — from ad-hoc scripts to your test fixtures — so the runtime is initialised identically in every context.