qt.qpa.plugin: Could Not Load the Qt Platform Plugin xcb in PyQGIS
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:
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, minimal, offscreen, vnc, wayland, xcb.
Aborted (core dumped)
Related variants of the same problem:
qt.qpa.screen: QXcbConnection: Could not connect to display
QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-root'
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, its dependencies or its display do not.
Common causes:
- no X server: a server, a container, a CI runner, or SSH without
-X DISPLAYunset, or set to a display that does not exist- the container image lacks the X client libraries
xcbdepends on (libxcb-*,libGL) QT_QPA_PLATFORMset after Qt has already been imported- 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
Quick answer
For an automated PyQGIS job, do not use a display at all:
- set
QT_QPA_PLATFORM=offscreenbefore any Qt or QGIS import - construct
QgsApplication([], False)β theFalsemeans "no GUI" - set
XDG_RUNTIME_DIRto a writable directory to silence the warning - if something genuinely needs X11 (some rendering paths), run under
xvfb-run - in a container, install the X client libraries even for offscreen use
import os
os.environ["QT_QPA_PLATFORM"] = "offscreen" # must precede the imports below
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
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()
print("QGIS started headless")
qgs.exitQgis()
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
Step-by-step solution
Confirm what Qt sees
Turn on the plugin loader's debug output; it prints every path searched and every dependency that failed.
QT_DEBUG_PLUGINS=1 python task.py 2>&1 | head -40
Look for two things: the directories searched (QFactoryLoader::QFactoryLoader() checking directory path β¦) and the reason a candidate was rejected (Cannot load library β¦: libxcb-icccm.so.4: cannot open shared object file). The second form means a missing system library rather than a missing display.
echo "DISPLAY=${DISPLAY:-<unset>}"
echo "QT_QPA_PLATFORM=${QT_QPA_PLATFORM:-<unset>}"
echo "QT_PLUGIN_PATH=${QT_PLUGIN_PATH:-<unset>}"
Set the platform before importing Qt
Qt reads QT_QPA_PLATFORM when the QGuiApplication is created, but the import of qgis.core already pulls in Qt libraries β so setting the variable after the import is unreliable.
# correct: environment first, imports second
import os
os.environ["QT_QPA_PLATFORM"] = "offscreen"
from qgis.core import QgsApplication
# unreliable: Qt may already be loaded
from qgis.core import QgsApplication
import os
os.environ["QT_QPA_PLATFORM"] = "offscreen"
Setting it in the launcher β the crontab line, the systemd unit, the Dockerfile β removes the ordering question entirely.
Choose between offscreen and xvfb
offscreen gives Qt a null platform: 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.
QT_QPA_PLATFORM=offscreen python export_maps.py
xvfb-run starts a real, virtual X server. Use it when something in the stack insists on a display β certain OpenGL paths, some plugins, or 3D rendering.
xvfb-run -a --server-args="-screen 0 1920x1080x24" python export_3d.py
Try offscreen first: it is faster, needs no extra process, and does not depend on a display number being free.
Install the libraries the plugin depends on
Even offscreen links against parts of the X client stack in most builds. Minimal container images do not ship them.
FROM qgis/qgis:release-3_40
ENV QT_QPA_PLATFORM=offscreen \
XDG_RUNTIME_DIR=/tmp/runtime-qgis \
PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \
libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xinerama4 \
libxkbcommon-x11-0 xvfb \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /tmp/runtime-qgis && chmod 700 /tmp/runtime-qgis
WORKDIR /srv/gis
COPY . .
CMD ["python3", "task.py"]
QT_DEBUG_PLUGINS=1 names the exact missing .so when one of these is absent, so the list above is a starting point rather than gospel for every image.
Silence XDG_RUNTIME_DIR, and know it is not the cause
export XDG_RUNTIME_DIR=/tmp/runtime-qgis
mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"
This warning appears in almost every headless Qt log and is unrelated to the plugin failure. 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.
import PyQt5, sys
from PyQt5.QtCore import QT_VERSION_STR, QLibraryInfo
print("python :", sys.executable)
print("PyQt5 :", PyQt5.__file__)
print("Qt :", QT_VERSION_STR)
print("plugins :", QLibraryInfo.location(QLibraryInfo.PluginsPath))
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 PyQt that ship with QGIS:
unset QT_PLUGIN_PATH
/usr/bin/python3 task.py # the system Python that QGIS was built against
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).
ssh -X gis@server # DISPLAY is set inside the session
echo $DISPLAY # localhost:10.0
A cron job started later has no such display. This is exactly why "it worked when I ran it over SSH" and "it fails from cron" so often appear together.
Code examples
Example 1: a headless bootstrap module
"""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)
# keep GDAL and PROJ quiet and predictable in logs
os.environ.setdefault("CPL_LOG", "/dev/null")
os.environ.setdefault("PROJ_NETWORK", "OFF")
configure_headless()
import headless # noqa: F401 β must be the first import
from qgis.core import QgsApplication
The noqa comment matters: an auto-formatter that sorts imports will otherwise move the line and reintroduce the bug.
Example 2: fall back to xvfb automatically
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)
return subprocess.run(
["xvfb-run", "-a", "--server-args=-screen 0 1920x1080x24", sys.executable, script]
).returncode
return proc.returncode
raise SystemExit(run_task("export_maps.py"))
Example 3: exporting a map layout headless
import headless # noqa: F401
from qgis.core import (
QgsApplication, QgsProject, QgsLayoutExporter,
)
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()
try:
project = QgsProject.instance()
project.read("/srv/gis/atlas.qgz")
layout = project.layoutManager().layoutByName("A3 Overview")
if layout is None:
raise SystemExit("layout not found")
exporter = QgsLayoutExporter(layout)
settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = 300
result = exporter.exportToImage("/srv/gis/out/overview.png", settings)
if result != QgsLayoutExporter.Success:
raise SystemExit(f"export failed with code {result}")
print("wrote overview.png")
finally:
QgsProject.instance().clear()
qgs.exitQgis()
Layout export renders through Qt, which is exactly the operation people expect to need a display β and it works perfectly under offscreen.
Example 4: 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_maps.py
Explanation
Qt abstracts the windowing system behind the Qt Platform Abstraction. At start-up, QGuiApplication 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.
The wording "could not load β¦ even though it was found" distinguishes two of those steps. Qt located libqxcb.so in the plugins directory, so the search path is fine; loading it failed. That happens either because one of its shared-library dependencies is missing β the usual case in a slim container β or because it loaded and then could not connect to a display. 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 lock β 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.
Edge cases or notes
QgsApplication([], False)is not enough on its own: TheFalsedisables GUI features, but Qt still initialises a platform plugin. You needQT_QPA_PLATFORMtoo.minimalversusoffscreen:minimalis even more stripped down and cannot render. Useoffscreenfor anything that produces an image.- 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 a shared library it depends on is missing, or there is no display to connect to. 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 as the very first statement of the script, before any import that pulls in Qt. Setting it after from qgis.core import β¦ is unreliable.
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?
Start from an official QGIS image, then add libgl1, the libxcb-* client libraries and libxkbcommon-x11-0. Use QT_DEBUG_PLUGINS=1 to see exactly which shared object your build 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.