Render a Layer to an Image Headless in PyQGIS
A map that has to be produced every night, on a server, without anyone opening QGIS, is a different problem from one produced interactively. There is no iface, no canvas and no window — and the parts of the API that depend on them fail in ways that are hard to read, typically an immediate segmentation fault rather than an exception. The workable subset is qgis.core plus a render job, and the initialisation ritual that gets you there is short but unforgiving about order.
This page is a focused recipe within Map Canvas Control and Image Export in PyQGIS. It covers standalone application startup, building the map settings by hand, running the render job, and the practical details of running the result under a scheduler or in a container.
Prerequisites
- QGIS 3.34 LTR installed on the machine that will run the script, with its Python bindings available.
- The Python interpreter QGIS was built against — mixing interpreters is the most common cause of an import failure. See Running Python Scripts Outside QGIS Desktop.
- A data source and an output directory the scheduled user can write to.
The complete standalone script
Everything below is one file, runnable with the QGIS Python interpreter and nothing else.
import sys
from qgis.core import (
QgsApplication,
QgsMapRendererParallelJob,
QgsMapSettings,
QgsVectorLayer,
)
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor
QgsApplication.setPrefixPath("/usr", True)
app = QgsApplication([], False)
app.initQgis()
try:
layer = QgsVectorLayer("/data/parcels.gpkg|layername=parcels", "parcels", "ogr")
if not layer.isValid():
raise RuntimeError("could not load the layer")
layer.loadNamedStyle("/data/styles/parcels.qml")
settings = QgsMapSettings()
settings.setLayers([layer])
settings.setDestinationCrs(layer.crs())
settings.setBackgroundColor(QColor("#ffffff"))
settings.setOutputDpi(150)
extent = layer.extent()
extent.scale(1.1)
width = 1600
height = int(width * extent.height() / extent.width())
settings.setOutputSize(QSize(width, height))
settings.setExtent(extent)
job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
job.renderedImage().save("/data/exports/parcels.png", "png")
print("rendered", width, "x", height)
finally:
app.exitQgis()
Breakdown: setPrefixPath() tells QGIS where its resources live — /usr on most Linux distributions, and the path containing bin and apps on Windows. The second argument to QgsApplication is useGui; passing False is what makes the process viable without a display. loadNamedStyle() applies a .qml file saved from the desktop, which is how a carefully designed style reaches a headless run without being rebuilt in code — see Programmatic Layer Styling for the alternative. The try/finally guarantees exitQgis() runs even on failure; without it the process can hang after an exception, which under cron means a job that never reports.
Choose the right job class
There are two render jobs and the difference matters more headless than it does on the desktop.
from qgis.core import QgsMapRendererSequentialJob
job = QgsMapRendererSequentialJob(settings)
job.start()
job.waitForFinished()
image = job.renderedImage()
Breakdown: QgsMapRendererParallelJob renders layers concurrently on worker threads, which is faster but needs a functioning Qt event loop to coordinate them. In some minimal container images that loop is not running the way it expects, and waitForFinished() blocks forever. QgsMapRendererSequentialJob draws the layers one after another on the calling thread — slower on a multi-layer map, but it has no such dependency. If a headless render hangs and you have already confirmed waitForFinished() is being called, switching job classes is the first thing to try.
Render without a display server
On a Linux server with no X or Wayland session, Qt still expects a display unless told otherwise.
export QT_QPA_PLATFORM=offscreen
export PYTHONPATH=/usr/share/qgis/python
/usr/bin/python3 /opt/maps/render_parcels.py
Breakdown: QT_QPA_PLATFORM=offscreen selects Qt's offscreen platform plugin, which satisfies the requirement for a platform without needing any display server. PYTHONPATH points at the QGIS Python packages; the exact path varies by distribution, and python3 -c "import qgis" is the quickest way to confirm it. Setting these in the environment rather than in the script keeps the script itself portable between a developer's desktop and the server.
Under cron, remember that almost none of your interactive environment is present. Export the variables inside the crontab entry or a wrapper script, use absolute paths everywhere, and redirect both streams to a log — a headless render that fails silently at 3 a.m. is otherwise invisible until someone notices a stale image.
Verify the output before trusting it
A headless render fails quietly. The layer does not load, the extent is empty, or the style file is missing — and the script still writes a perfectly valid PNG of nothing at all. Four assertions turn that into a job that reports its own failure.
def render_checked(layer, settings, out_path):
if not layer.isValid():
raise RuntimeError(f"layer did not load: {layer.source()}")
if layer.featureCount() == 0:
raise RuntimeError(f"layer {layer.name()} has no features")
extent = layer.extent()
if extent.isNull() or extent.isEmpty():
raise RuntimeError(f"layer {layer.name()} has no usable extent")
extent.scale(1.1)
settings.setExtent(extent)
job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
image = job.renderedImage()
corner = image.pixel(0, 0)
middle = image.pixel(image.width() // 2, image.height() // 2)
if corner == middle and image.allGray():
raise RuntimeError("rendered image looks uniform — nothing drew")
image.save(out_path, "png")
return out_path
Breakdown: isValid() is the first and most valuable check — an invalid layer renders as nothing and reports no error anywhere. featureCount() == 0 catches an over-aggressive subsetString left behind by earlier code, which is otherwise indistinguishable from a styling problem. Comparing a corner pixel with a centre pixel is a crude but effective smoke test: a real map is almost never uniform, so a match strongly suggests the extent or the style is wrong. Raising rather than logging means the scheduler sees a non-zero exit code, which is what turns a silent 3 a.m. failure into an alert.
For a job that runs unattended, pair these assertions with logging the output file's size and modification time. A PNG that is suspiciously small — a few hundred bytes — is almost always a blank canvas, and noticing that in a log is far cheaper than noticing it in a report a week later.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical startup sequence and job classes. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | QgsApplication.setPrefixPath() is optional on some packaged builds that detect it; setting it explicitly remains the safe choice. |
The standalone initialisation pattern has been stable throughout 3.x, and is the same one Processing scripts use outside the desktop.
Troubleshooting
- Segmentation fault on startup.
setPrefixPath()was not called, or points somewhere without the QGIS resources. There is no traceback because the crash is below Python. ImportError: No module named qgis. The interpreter is not the one QGIS was built against, orPYTHONPATHis missing the QGIS Python directory.- The script hangs at
waitForFinished(). Switch fromQgsMapRendererParallelJobtoQgsMapRendererSequentialJob. could not connect to display. SetQT_QPA_PLATFORM=offscreen, or run the script underxvfb-run.- The process never exits under cron.
exitQgis()was skipped, probably because an exception escaped. Wrap the body intry/finally. - The image is blank. The layer failed to load. Always check
layer.isValid()and raise — an invalid layer renders as nothing, silently.
Conclusion
Headless rendering is the standalone initialisation ritual plus the render-job pattern: set the prefix path, construct QgsApplication with the GUI disabled, build QgsMapSettings by hand, render synchronously, and always reach exitQgis(). Once that skeleton is right, the map itself is exactly the same code you would write interactively.
Frequently Asked Questions
Why does my standalone script crash immediately?QgsApplication.setPrefixPath() was not called before constructing the application, so QGIS cannot find its resources. The failure is a segmentation fault rather than a Python exception, which is why there is no traceback.
Do I need a display server to render a map?
No. Set QT_QPA_PLATFORM=offscreen and Qt uses its offscreen platform plugin. A virtual framebuffer such as xvfb-run is only needed for code paths that create real widgets.
Why does waitForFinished() never return?QgsMapRendererParallelJob coordinates worker threads through the Qt event loop, which some minimal environments do not provide as expected. Use QgsMapRendererSequentialJob instead.
How do I apply my styling in a headless script?
Save the style from QGIS Desktop as a .qml file and call layer.loadNamedStyle() after loading the layer. Rebuilding the renderer in code is possible but rarely worth it for a design that already exists.
Can I run several headless renders in parallel?
Run them as separate processes rather than threads. A single QgsApplication is not designed to be driven concurrently from multiple Python threads, and sharing one between them produces intermittent crashes rather than errors.
Does a headless render honour layer scale-visibility ranges? Yes. A layer outside its configured scale range is skipped exactly as it would be on screen, which surprises people whose export comes out missing a detail layer.