qt.qpa.plugin: Could Not Load the Qt Platform Plugin xcb in PyQGIS
On this page
Problem statement
The PyQGIS script runs fine on your desktop. On the server, in a container, or over SSH it aborts before doing any work. Here is the one-line version, run in the official qgis/qgis:ltr Docker image, which has no display:
print(in_qgis_container('python3 -c "from qgis.core import QgsApplication; QgsApplication([], False)"'))
exit status 134
qt.qpa.xcb: could not connect to display
qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "" even though it was found.
This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.
Available platform plugins are: eglfs, linuxfb, minimal, minimalegl, offscreen, vnc, wayland-egl, wayland, wayland-xcomposite-egl, wayland-xcomposite-glx, xcb.
Aborted (core dumped)
QGIS is built on Qt, and Qt loads a platform plugin at start-up to talk to a windowing system. On a headless machine there is no windowing system, so xcb β the X11 plugin β cannot connect and Qt aborts. The message says "could not load β¦ even though it was found", which is confusing: the plugin file exists; what it needs β a display, or one of its libraries β does not.
Which of those is missing depends on where the script runs:
- no X server: a server, a container, a CI runner, or SSH without
-X DISPLAYunset, or set to a display that does not exist- a slim image without the X client libraries
xcbdepends on (libxcb-*,libGL) QT_QPA_PLATFORMset only after theQgsApplicationhas been created- two Qt installations in one environment (conda PyQt plus system QGIS)
QT_PLUGIN_PATHpointing at plugins from a different Qt buildXDG_RUNTIME_DIRunset, which is a warning rather than the cause
Every command on this page ran in qgis/qgis:ltr β QGIS 3.44 on Qt 5 β with Rutland's OpenStreetMap land use as the layer to draw.
Quick answer
For an automated PyQGIS job, do not use a display at all:
- set
QT_QPA_PLATFORM=offscreenbeforeQgsApplicationis created β in the launcher, or at the top of the script - construct
QgsApplication([], False)β theFalsemeans "no GUI", not "no display needed" - set
XDG_RUNTIME_DIRto a writable directory to silence the warning - if something genuinely needs X11 (some OpenGL paths), run under
xvfb-run - in a slim container, install the X client libraries even for offscreen use
%%writefile task.py
import os
os.environ["QT_QPA_PLATFORM"] = "offscreen" # before QgsApplication is created
os.environ.setdefault("XDG_RUNTIME_DIR", "/tmp/runtime-qgis")
os.makedirs(os.environ["XDG_RUNTIME_DIR"], mode=0o700, exist_ok=True)
from qgis.core import QgsApplication, Qgis
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()
print("QGIS", Qgis.QGIS_VERSION, "started headless")
qgs.exitQgis()
print(in_qgis_container("python3 task.py"))
QGIS 3.44.15-Solothurn started headless
exit status 0
Or set it outside the process, which is tidier in a scheduler:
QT_QPA_PLATFORM=offscreen /usr/bin/python3 /srv/gis/task.py
Which platform plugin to use
See what Qt sees, then set the platform first
Confirm what Qt sees
Turn on the plugin loader's debug output; it prints every path searched and every candidate it looked at:
debug = in_qgis_container('python3 -c "from qgis.core import QgsApplication; QgsApplication([], False)"',
env={"QT_DEBUG_PLUGINS": "1"})
for line in debug.splitlines():
if any(key in line for key in ("checking directory", "libqxcb", "connect to display", "Cannot load", "Aborted")):
print(line.strip()[:110])
QFactoryLoader::QFactoryLoader() checking directory path "/usr/lib/x86_64-linux-gnu/qt5/plugins/platforms" ...
QFactoryLoader::QFactoryLoader() looking at "/usr/lib/x86_64-linux-gnu/qt5/plugins/platforms/libqxcb.so"
Found metadata in lib /usr/lib/x86_64-linux-gnu/qt5/plugins/platforms/libqxcb.so, metadata=
QFactoryLoader::QFactoryLoader() checking directory path "/usr/bin/platforms" ...
loaded library "/usr/lib/x86_64-linux-gnu/qt5/plugins/platforms/libqxcb.so"
qt.qpa.xcb: could not connect to display
Aborted (core dumped)
Look for two things: the directories searched, and the reason a candidate was rejected. could not connect to display means there is no X server; Cannot load library β¦: libxcb-icccm.so.4: cannot open shared object file would mean a missing system library instead.
echo "DISPLAY=${DISPLAY:-<unset>}"
echo "QT_QPA_PLATFORM=${QT_QPA_PLATFORM:-<unset>}"
echo "QT_PLUGIN_PATH=${QT_PLUGIN_PATH:-<unset>}"
Set the platform before the application exists
Qt reads QT_QPA_PLATFORM when the application object is constructed. Importing qgis.core loads Qt libraries but does not construct it, so the variable can come after the import β as long as it comes before QgsApplication(...):
%%writefile after_import.py
from qgis.core import QgsApplication, Qgis
import os
os.environ["QT_QPA_PLATFORM"] = "offscreen" # after the import, before the application
app = QgsApplication([], False)
app.initQgis()
print("started", Qgis.QGIS_VERSION)
app.exitQgis()
print(in_qgis_container("python3 after_import.py"))
started 3.44.15-Solothurn
exit status 0
QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-ubuntu'
It still belongs at the very top, before anything else β a library that creates its own Qt application during import would read it too early β and in the launcher (the crontab line, the systemd unit, the Dockerfile) the ordering question disappears entirely.
Choose between offscreen and xvfb
offscreen gives Qt a platform with no screen: widgets exist, nothing is displayed, and rendering happens in memory. It covers virtually all automation: reading layers, running Processing algorithms, exporting layouts to PDF or PNG. Here is a print layout of Rutland's land use exported to PNG under each non-display platform:
for platform in ("offscreen", "minimal"):
print(in_qgis_container("python3 export_layout.py", env={"QT_QPA_PLATFORM": platform}).strip())
offscreen: export result 0, 1169x826 px, 243 distinct colours sampled
exit status 0
QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-ubuntu'
ERROR 6: The PNG driver does not support update access to existing datasets.
minimal: export result 0, 1169x826 px, 238 distinct colours sampled
exit status 0
QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-ubuntu'
ERROR 6: The PNG driver does not support update access to existing datasets.
xvfb-run starts a real, virtual X server. Use it when something in the stack insists on a display β certain OpenGL paths, some plugins, 3D rendering. The official image ships it:
print(in_qgis_container("xvfb-run -a python3 export_layout.py").strip())
xcb under xvfb-run: export result 0, 1169x826 px, 231 distinct colours sampled
exit status 0
QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-ubuntu'
ERROR 6: The PNG driver does not support update access to existing datasets.
All three produced the same full-size map; the ERROR 6 line is GDAL declining to write georeferencing into a PNG it cannot update, not a rendering failure. Try offscreen first: it needs no extra process and no free display number.
Install the libraries the plugin depends on
Even offscreen links against parts of the X client stack in most builds:
print(in_qgis_container("ldd /usr/lib/x86_64-linux-gnu/qt5/plugins/platforms/libqoffscreen.so | "
"grep -E 'libX11|libGL.so|libxcb' | sed 's/ (0x.*//'"))
libGL.so.1 => /lib/x86_64-linux-gnu/libGL.so.1
libX11.so.6 => /lib/x86_64-linux-gnu/libX11.so.6
libxcb.so.1 => /lib/x86_64-linux-gnu/libxcb.so.1
exit status 0
The official QGIS images already have them. A slim image of your own needs them installed:
FROM qgis/qgis:ltr
ENV QT_QPA_PLATFORM=offscreen \
XDG_RUNTIME_DIR=/tmp/runtime-qgis \
PYTHONUNBUFFERED=1
RUN mkdir -p /tmp/runtime-qgis && chmod 700 /tmp/runtime-qgis
WORKDIR /srv/gis
COPY . .
CMD ["python3", "task.py"]
On a slim base, add libgl1, the libxcb-* client libraries and libxkbcommon-x11-0 β QT_DEBUG_PLUGINS=1 names the exact missing .so when one of them is absent, so treat any list as a starting point rather than gospel for every image.
Silence XDG_RUNTIME_DIR, and know it is not the cause
for env in ({}, {"XDG_RUNTIME_DIR": "/tmp"}):
lines = in_qgis_container("python3 after_import.py", env=env).splitlines()
print(env or "no XDG_RUNTIME_DIR:", [l for l in lines if "XDG" in l or l.startswith("started")])
no XDG_RUNTIME_DIR: ['started 3.44.15-Solothurn', "QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-ubuntu'"]
{'XDG_RUNTIME_DIR': '/tmp'} ['started 3.44.15-Solothurn']
This warning appears in almost every headless Qt log and is unrelated to the plugin failure β the application started both times. Setting it keeps the log clean so the real error stands out.
Resolve conflicting Qt installations
A conda environment with pyqt alongside a system QGIS gives you two Qt builds. Whichever gets loaded first wins, and its plugin path will not match the other. Ask the Qt that QGIS actually loaded where its plugins are, through the qgis.PyQt shim so the same code works on Qt 5 and Qt 6:
%%writefile which_qt.py
import sys
from qgis.PyQt.QtCore import QT_VERSION_STR, QLibraryInfo
try:
plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath) # Qt 6
except AttributeError:
plugins = QLibraryInfo.location(QLibraryInfo.PluginsPath) # Qt 5
print("python :", sys.executable if sys.executable.startswith("/usr") else "a virtualenv over the system Python")
print("Qt :", QT_VERSION_STR)
print("plugins:", plugins)
print("container:\n" + in_qgis_container("python3 which_qt.py"))
print("this machine:\n" + subprocess.run([sys.executable, "which_qt.py"], capture_output=True, text=True).stdout)
container:
python : /usr/bin/python3
Qt : 5.15.17
plugins: /usr/lib/x86_64-linux-gnu/qt5/plugins
exit status 0
this machine:
python : a virtualenv over the system Python
Qt : 6.10.2
plugins: /usr/lib/x86_64-linux-gnu/qt6/plugins
If the plugin path points into a conda environment while QGIS came from apt, that mismatch is the bug. Unset QT_PLUGIN_PATH, and use the interpreter and Qt bindings that ship with QGIS.
Forward the display only if you really need it
Over SSH, ssh -X sets DISPLAY and forwards X11 β fine for a one-off interactive run, wrong for a scheduled job (the display disappears when the session ends). A cron job started later has no such display, which is exactly why "it worked when I ran it over SSH" and "it fails from cron" so often appear together.
Headless bootstraps for Rutland's land use
The data used on this page
OpenStreetMap land use for Rutland from Geofabrik's 1 January 2026 extract (Β© OpenStreetMap contributors, ODbL), as landuse.gpkg in the working directory, which is mounted into the official qgis/qgis:ltr image. in_qgis_container() runs a shell command there with a clean environment β no DISPLAY, no QT_QPA_PLATFORM unless one is passed β and returns what it printed, exit status included.
import os
import shutil
import subprocess
import sys
from pathlib import Path
import requests
from osgeo import gdal
gdal.UseExceptions()
if not Path("landuse.gpkg").exists():
Path("rutland.shp.zip").write_bytes(requests.get(
"https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
gdal.VectorTranslate("landuse.gpkg", "/vsizip/rutland.shp.zip/gis_osm_landuse_a_free_1.shp",
layerName="landuse", dstSRS="EPSG:27700", reproject=True)
Path("export_layout.py").write_text('''\
import os
from qgis.core import (QgsApplication, QgsProject, QgsVectorLayer, QgsPrintLayout, QgsLayoutItemMap,
QgsLayoutItemLabel, QgsLayoutExporter, QgsLayoutPoint, QgsLayoutSize, Qgis)
from qgis.PyQt.QtGui import QImage
app = QgsApplication([], False)
app.initQgis()
project = QgsProject.instance()
layer = QgsVectorLayer("landuse.gpkg|layername=landuse", "Land use", "ogr")
project.addMapLayer(layer)
layout = QgsPrintLayout(project)
layout.initializeDefaults()
map_item = QgsLayoutItemMap(layout)
map_item.attemptMove(QgsLayoutPoint(10, 20))
map_item.attemptResize(QgsLayoutSize(277, 180))
map_item.setExtent(layer.extent())
map_item.setLayers([layer])
layout.addLayoutItem(map_item)
title = QgsLayoutItemLabel(layout)
title.setText("Rutland land use")
title.attemptMove(QgsLayoutPoint(10, 5))
title.adjustSizeToText()
layout.addLayoutItem(title)
settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = 100
name = f"overview_{os.environ.get('QT_QPA_PLATFORM', 'xvfb')}.png"
result = QgsLayoutExporter(layout).exportToImage(name, settings)
image = QImage(name)
colours = len({image.pixel(x, y) for x in range(0, image.width(), 20) for y in range(0, image.height(), 20)})
print(f"{os.environ.get('QT_QPA_PLATFORM', 'xcb under xvfb-run')}: export result {result}, "
f"{image.width()}x{image.height()} px, {colours} distinct colours sampled")
project.clear()
app.exitQgis()
''')
IMAGE = "qgis/qgis:ltr"
HERE = str(Path.cwd())
def in_qgis_container(command, env=None):
"""Run a shell command in the official QGIS image, with no display, and return its output."""
flags = [f"-e{k}={v}" for k, v in (env or {}).items()]
run = subprocess.run(["docker", "run", "--rm", "-v", f"{HERE}:/work", "-w", "/work",
"--user", f"{os.getuid()}:{os.getgid()}", "-e", "HOME=/tmp", *flags, IMAGE,
"sh", "-c", f"unset DISPLAY; {command}; echo \"exit status $?\""],
capture_output=True, text=True, timeout=600)
lines = [line for line in (run.stdout + run.stderr).splitlines()
if line.strip() and "QThreadStorage" not in line]
return "\n".join(lines).replace(HERE, "/work")
print(in_qgis_container('python3 -c "from qgis.core import Qgis; print(Qgis.QGIS_VERSION)"'))
3.44.15-Solothurn
exit status 0
Example 1: a headless bootstrap module
%%writefile headless.py
"""headless.py β import this first, before anything Qt-related."""
import os
from pathlib import Path
def configure_headless(runtime_dir="/tmp/runtime-qgis") -> None:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
os.environ.setdefault("XDG_RUNTIME_DIR", runtime_dir)
Path(runtime_dir).mkdir(mode=0o700, parents=True, exist_ok=True)
configure_headless()
%%writefile job.py
import headless # noqa: F401 β must be the first import
import os
from qgis.core import QgsApplication, QgsVectorLayer
qgs = QgsApplication([], False)
qgs.initQgis()
layer = QgsVectorLayer("landuse.gpkg|layername=landuse", "landuse", "ogr")
print(f"platform {os.environ['QT_QPA_PLATFORM']}: {layer.featureCount()} land-use polygons")
del layer # before exitQgis(), or the process segfaults on the way out
qgs.exitQgis()
print(in_qgis_container("python3 job.py"))
platform offscreen: 2405 land-use polygons
exit status 0
The noqa comment matters: an auto-formatter that sorts imports will otherwise move the line, and a module that must run first is a module that must stay first. The del layer matters too: without it this job printed its count and then died with a segmentation fault, exit status 139, because the layer outlived the application β see PyQGIS segmentation fault.
Example 2: fall back to xvfb automatically
%%writefile run_task.py
import os, shutil, subprocess, sys
def run_task(script: str) -> int:
env = dict(os.environ, QT_QPA_PLATFORM="offscreen")
proc = subprocess.run([sys.executable, script], env=env)
if proc.returncode == 0:
return 0
if shutil.which("xvfb-run"):
print("offscreen failed β retrying under xvfb", file=sys.stderr)
env.pop("QT_QPA_PLATFORM")
return subprocess.run(["xvfb-run", "-a", "--server-args=-screen 0 1920x1080x24", sys.executable, script],
env=env).returncode
return proc.returncode
raise SystemExit(run_task(sys.argv[1]))
print(in_qgis_container("python3 run_task.py export_layout.py"))
offscreen: export result 0, 1169x826 px, 245 distinct colours sampled
exit status 0
QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-ubuntu'
ERROR 6: The PNG driver does not support update access to existing datasets.
Example 3: a systemd unit that sets the environment properly
# /etc/systemd/system/qgis-export.service
[Service]
Type=oneshot
User=gis
WorkingDirectory=/srv/gis
Environment=QT_QPA_PLATFORM=offscreen
Environment=XDG_RUNTIME_DIR=/tmp/runtime-qgis
Environment=PYTHONUNBUFFERED=1
ExecStartPre=/usr/bin/install -d -m 700 -o gis /tmp/runtime-qgis
ExecStart=/usr/bin/python3 /srv/gis/export_layout.py
Layout export renders through Qt, which is exactly the operation people expect to need a display β and it worked under both offscreen and minimal above.
Qt's platform abstraction
Qt abstracts the windowing system behind the Qt Platform Abstraction. When the application object is created, Qt picks a platform plugin β xcb for X11, wayland, cocoa on macOS, windows on Windows β and if none can be initialised, Qt aborts rather than continue in an undefined state. That abort is a hard exit, so no Python exception handler runs and no traceback appears β only exit status 134.
The wording "could not load β¦ even though it was found" means Qt located libqxcb.so in the plugins directory, so the search path is fine; initialising it failed. That happens either because it could not connect to a display β the case in the container above β or because one of its shared-library dependencies is missing, the usual case in a slim image built by hand. The debug output from QT_DEBUG_PLUGINS=1 tells you which, and that single distinction saves most of the guesswork.
offscreen sidesteps the whole question. It is a real platform plugin that implements the interface against an in-memory surface: windows exist as objects, painting happens into image buffers, and nothing is ever shown. Because QGIS renders maps and layouts through QPainter rather than through the screen, virtually all automation β Processing runs, layout export to PDF or PNG, atlas generation β works under it unchanged.
xvfb solves the problem differently, by making the assumption true: it starts an X server that draws into memory. That is heavier β an extra process and a display number β but it is the fallback when a component genuinely requires X11, most often something using OpenGL. The practical rule is to run offscreen by default and reach for xvfb-run only when a specific step fails without it.
offscreen, minimal, macOS and Wayland
QgsApplication([], False)is not enough on its own: TheFalsedisables GUI features, but Qt still initialises a platform plugin. You needQT_QPA_PLATFORMtoo.minimalversusoffscreen:minimalis more stripped down, and in QGIS 3.44 it still exported a layout image.offscreenis the one the Qt and QGIS projects document for headless rendering; prefer it.- macOS uses
cocoa: There is noxcbthere. Headless macOS runs still benefit fromQT_QPA_PLATFORM=offscreen. - Wayland desktops: If
xcbfails on a Wayland session, tryQT_QPA_PLATFORM=waylandfor interactive use β but automation should still useoffscreen. xvfb-runand display collisions: Without-a, two concurrent jobs fight over display:99. Always pass-ato pick a free one.- Fonts: Layout exports on a bare container can render boxes instead of text. Install
fonts-dejavu-coreor the fonts your layouts use. - The warning about
XDG_RUNTIME_DIRis harmless: It is noise, not the cause. Set it to keep logs readable.
Internal links
- How to Run a PyQGIS Script Headless Without Opening QGIS
- How to Style Layers and Export Map Layouts from PyQGIS
- PyQGIS Script Crashes with a Segmentation Fault: How to Fix It
- How to Fix "ModuleNotFoundError: No module named 'qgis'"
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- Python GIS Script Works Manually but Not from Cron: How to Fix It
FAQ
What does "could not load the Qt platform plugin xcb even though it was found" mean?
Qt located the plugin file but could not initialise it β either there is no display to connect to (the case in the official QGIS container, which logs could not connect to display first), or a shared library it depends on is missing. Run with QT_DEBUG_PLUGINS=1 to see which.
Is offscreen or xvfb the right choice?
offscreen for almost everything, including layout and image export: it is faster and needs no extra process. Use xvfb-run -a only when a component genuinely requires a real X server, such as some OpenGL paths.
Where exactly do I set QT_QPA_PLATFORM?
In the launcher β the crontab line, systemd unit, or Dockerfile β or at the top of the script. What matters is that it is set before QgsApplication(...) is constructed: in the QGIS 3.44 container it worked even when set after from qgis.core import β¦, but a library that builds a Qt application during import would defeat that, so the top of the script is the safe place.
Why does it work over SSH but not from cron?
ssh -X sets DISPLAY and forwards X11 for the duration of that session. A cron job has no session and no display, so xcb cannot connect. Use offscreen and the difference disappears.
Which packages does a Docker image need?
The official qgis/qgis images already carry what the platform plugins need β offscreen itself links libX11 and libGL. On a slim base of your own, add libgl1, the libxcb-* client libraries and libxkbcommon-x11-0, and use QT_DEBUG_PLUGINS=1 to see exactly which shared object is missing.
Can I export a PDF or PNG map layout without a display?
Yes. QgsLayoutExporter renders through Qt's painting stack, which works fully under the offscreen platform. Just make sure the fonts used by the layout are installed.
What is XDG_RUNTIME_DIR and do I have to set it?
It is where Qt puts runtime files. Not setting it produces a warning, not a failure. Point it at a writable 700 directory to keep the log clean.