How to Style Layers and Export Map Layouts from PyQGIS
On this page
Most GIS automation stops at the data and hands a GeoPackage to someone who then spends an afternoon making maps. That last mile is the part QGIS is uniquely good at, and it is fully scriptable: a renderer applied from Python looks identical to one set in the Layer Styling panel, a print layout built in code exports the same PDF as one built by dragging, and an atlas turns one layout into a map per district while you do something else. This guide covers the styling API, the layout API, and the export settings that decide whether the output is publishable — all run headless under QGIS 4.2 on Rutland's land use, its 21,253 Land Registry parcels and its 15 wards.
Problem statement
The analysis runs unattended and the maps do not. Concretely:
- Styling is redone by hand each month — the same graduated ramp, the same breaks, applied by clicking.
- A
.qmlexists but nothing applies it to the freshly produced layer. - Layouts live inside one person's project file and cannot be produced from a script.
- Exports come out wrong — 96 DPI when the print shop wants 300, the wrong extent, labels as boxes on a server with no fonts.
- Fifteen ward maps are fifteen manual exports, and someone always mislabels one.
- The whole thing needs a display — or so it seems, until you learn otherwise.
One headless script can load today's data, apply a saved style, place the map on a layout at a known scale, and write a 300 DPI PNG and a PDF.
Quick answer
Styling is a renderer on the layer; export is a layout plus an exporter.
from qgis.core import (QgsLayoutExporter, QgsLayoutItemMap, QgsLayoutPoint, QgsLayoutSize,
QgsPrintLayout, QgsProject, QgsVectorLayer)
project = QgsProject.instance()
MM = Qgis.LayoutUnit.Millimeters
layer = QgsVectorLayer("data/rutland.gpkg|layername=landuse", "Land use", "ogr")
message, ok = layer.loadNamedStyle("styles/landuse.qml") # a saved style
layer.triggerRepaint()
project.addMapLayer(layer)
project.setCrs(layer.crs()) # headless, nothing sets the project CRS for you
layout = QgsPrintLayout(project)
layout.initializeDefaults() # one A4 landscape page
map_item = QgsLayoutItemMap(layout)
map_item.attemptMove(QgsLayoutPoint(10, 10, MM))
map_item.attemptResize(QgsLayoutSize(277, 180, MM))
map_item.setExtent(layer.extent())
layout.addLayoutItem(map_item)
settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = 300
result = QgsLayoutExporter(layout).exportToImage("out/landuse.png", settings)
print("style applied:", ok, "| export:", QgsLayoutExporter.ExportResult(result).name,
"|", png_size("out/landuse.png"))
style applied: True | export: Success | 3507 x 2480 px
Run it with the headless bootstrap from running PyQGIS headless — QT_QPA_PLATFORM=offscreen is enough; no X server is required. The setup at the start of the code examples is that bootstrap.
Styles, renderers, labels, layouts and export settings
Reuse styles rather than rebuilding them
The fastest correct route to a good-looking map is to let a cartographer make it once in the GUI, save it as a .qml, and apply that from code. (The page's setup plays the cartographer: it builds the land-use style in code and saves it with saveNamedStyle.)
message, ok = layer.loadNamedStyle("styles/landuse.qml")
if not ok:
raise RuntimeError(f"style not applied: {message}")
print(type(layer.renderer()).__name__, "on", layer.renderer().classAttribute(),
"with", len(layer.renderer().categories()), "categories")
QgsCategorizedSymbolRenderer on fclass with 19 categories
Commit the .qml next to the code: it is XML, it diffs, and it makes the cartography reviewable in the same pull request as the analysis. This is the styling equivalent of keeping a Processing model in the repository rather than in a user profile.
The one thing to check is that the field names the style references still exist:
parcels_styled = QgsVectorLayer("data/rutland.gpkg|layername=parcels", "Parcels", "ogr")
message, ok = parcels_styled.loadNamedStyle("styles/landuse.qml") # classifies on "fclass"
print("applied:", ok, "| parcels has fclass:", parcels_styled.fields().indexOf("fclass") >= 0)
print("parcels drawn by that style:", drawn_pixels(parcels_styled), "non-white pixels")
applied: True | parcels has fclass: False
parcels drawn by that style: 0 non-white pixels
The style applied "successfully" to a layer without the classified field, and nothing was drawn: every parcel fell outside every category. Assert the fields a style needs before you apply it.
Build a renderer in code when the classes are data-driven
Single symbol — one look for every feature:
from qgis.core import QgsSingleSymbolRenderer, QgsSymbol
from qgis.PyQt.QtGui import QColor
parcels = QgsVectorLayer("data/rutland.gpkg|layername=parcels", "Parcels", "ogr")
symbol = QgsSymbol.defaultSymbol(parcels.geometryType())
symbol.setColor(QColor("#0ea5e9"))
symbol.setOpacity(0.85)
symbol.symbolLayer(0).setStrokeColor(QColor("#1a3a6b"))
parcels.setRenderer(QgsSingleSymbolRenderer(symbol))
parcels.triggerRepaint()
Categorized — one symbol per distinct value:
from qgis.core import QgsCategorizedSymbolRenderer, QgsRendererCategory
palette = {"residential": "#0ea5e9", "commercial": "#14b8a6", "industrial": "#d97706"}
categories = []
for value, colour in palette.items():
sym = QgsSymbol.defaultSymbol(layer.geometryType())
sym.setColor(QColor(colour))
categories.append(QgsRendererCategory(value, sym, value.title()))
layer.setRenderer(QgsCategorizedSymbolRenderer("fclass", categories))
layer.triggerRepaint()
counts = {c.value(): count(layer, f"\"fclass\" = '{c.value()}'") for c in categories}
print(counts, "of", layer.featureCount(), "land-use polygons get a symbol")
{'residential': 159, 'commercial': 8, 'industrial': 30} of 2405 land-use polygons get a symbol
A categorized renderer draws only the values it lists. Rutland's land use has 19 classes, not three, so only 197 of its 2,405 polygons got a symbol: farmland, forest, meadow and the rest disappeared from the map. Add a category with an empty value for "all other values" when that is not what you want.
Graduated — classes computed from a numeric field:
from qgis.core import QgsClassificationQuantile, QgsGraduatedSymbolRenderer, QgsStyle
graduated = QgsGraduatedSymbolRenderer("area_m2")
graduated.setSourceSymbol(QgsSymbol.defaultSymbol(parcels.geometryType()))
graduated.setSourceColorRamp(QgsStyle.defaultStyle().colorRamp("Blues"))
graduated.setClassificationMethod(QgsClassificationQuantile())
graduated.updateClasses(parcels, 5)
parcels.setRenderer(graduated)
parcels.triggerRepaint()
print([f"{r.lowerValue():,.0f}–{r.upperValue():,.0f}" for r in graduated.ranges()])
['0–157', '157–326', '326–573', '573–2,121', '2,121–7,149,834']
Build renderers in code when the breaks depend on the data — quantiles of this month's values — and load a .qml when the classification is fixed. Mixing the two is fine: load the .qml for the symbology defaults, then replace the renderer's class breaks. The classification object replaces the old createRenderer(..., QgsGraduatedSymbolRenderer.Quantile, ...) call, whose mode argument is deprecated.
triggerRepaint() after every change is a habit worth forming; without it a cached render can be exported instead of the new one.
Labels are a separate object
from qgis.core import QgsPalLayerSettings, QgsTextFormat, QgsVectorLayerSimpleLabeling
from qgis.PyQt.QtGui import QFont
wards = QgsVectorLayer("data/wards.geojson", "Wards", "ogr")
text = QgsTextFormat()
text.setFont(QFont("DejaVu Sans"))
text.setSize(9)
text.setColor(QColor("#1e293b"))
label_settings = QgsPalLayerSettings()
label_settings.fieldName = "WD24NM"
label_settings.setFormat(text)
label_settings.placement = Qgis.LabelPlacement.OverPoint # or Horizontal, AroundPoint, ...
wards.setLabelsEnabled(True)
wards.setLabeling(QgsVectorLayerSimpleLabeling(label_settings))
wards.triggerRepaint()
try:
label_settings.placement = QgsPalLayerSettings.OverPoint # the QGIS 3 spelling
except TypeError as exc:
print("TypeError:", exc)
TypeError: a member of enum 'LabelPlacement' is expected not 'LabelPredefinedPointPosition'
The QGIS 3 idiom fails in QGIS 4.2, and not because the name is gone: QgsPalLayerSettings.OverPoint still exists, but it is now a member of a different enum — the predefined point positions — and the setter refuses it. Use Qgis.LabelPlacement. fieldName also accepts an expression when you set label_settings.isExpression = True.
Assemble the layout
initializeDefaults() gives you a single A4 landscape page, which is enough for most report maps. Then add items, positioning each in millimetres.
from qgis.core import QgsLayoutItemLabel, QgsLayoutItemLegend, QgsLayoutItemScaleBar
project.addMapLayers([parcels, wards])
layout = QgsPrintLayout(project)
layout.initializeDefaults()
layout.setName("Rutland parcels")
map_item = QgsLayoutItemMap(layout)
map_item.attemptMove(QgsLayoutPoint(10, 25, MM))
map_item.attemptResize(QgsLayoutSize(200, 175, MM))
map_item.setLayers([wards, parcels])
map_item.setExtent(parcels.extent())
map_item.setFrameEnabled(True)
map_item.setId("mainmap")
layout.addLayoutItem(map_item)
title = QgsLayoutItemLabel(layout)
title.setText("Rutland parcels by area, INSPIRE Index Polygons")
title.setTextFormat(text)
title.setId("title")
title.adjustSizeToText()
title.attemptMove(QgsLayoutPoint(10, 8, MM))
layout.addLayoutItem(title)
legend = QgsLayoutItemLegend(layout)
legend.setTitle("Parcel area (m²)")
legend.setLinkedMap(map_item)
legend.attemptMove(QgsLayoutPoint(218, 25, MM))
layout.addLayoutItem(legend)
bar = QgsLayoutItemScaleBar(layout)
bar.setStyle("Single Box")
bar.setLinkedMap(map_item)
bar.applyDefaultSize()
bar.attemptMove(QgsLayoutPoint(218, 170, MM))
layout.addLayoutItem(bar)
project.layoutManager().addLayout(layout)
print(f"project CRS {project.crs().authid()}; map scale 1:{map_item.scale():,.0f}; "
f"scale bar {bar.numberOfSegments()} x {bar.unitsPerSegment():,.0f} {bar.unitLabel()}")
project CRS EPSG:27700; map scale 1:139,389; scale bar 2 x 2,500 m
setLinkedMap is what makes the legend and scale bar track the map — without it the legend shows every layer in the project and the bar has no scale to measure. setLayers fixes which layers the map item draws, whatever else the project holds.
Two ways to set what the map shows: setExtent(rect) for a bounding box, or setScale(25000) for a fixed scale around the current centre. Fixed scale is usually what a series of maps wants, so every sheet is comparable.
Reuse a designed layout as a template
Building layouts in code gets tedious past four items. Let a designer build one in the GUI, save it as a .qpt template, and load it. Here the layout just built is saved as the template:
from pathlib import Path
from qgis.core import QgsReadWriteContext
from qgis.PyQt.QtXml import QDomDocument
Path("layouts").mkdir(exist_ok=True)
layout.saveAsTemplate("layouts/rutland.qpt", QgsReadWriteContext())
doc = QDomDocument()
doc.setContent(Path("layouts/rutland.qpt").read_text())
from_template = QgsPrintLayout(project)
items, ok = from_template.loadFromTemplate(doc, QgsReadWriteContext())
print("template loaded:", ok, "| items:", sorted(i.id() for i in items if i.id()))
from_template.itemById("title").setText("Rutland parcels — from the template")
from_template.itemById("mainmap").setExtent(wards.extent())
template loaded: True | items: ['mainmap', 'title']
Setting an id on each item — in the layout designer's item properties, or with setId() — is the small discipline that makes this work: itemById is far more robust than walking layout.items() and guessing by type.
Export with settings you chose
import shutil
exporter = QgsLayoutExporter(layout)
image = QgsLayoutExporter.ImageExportSettings()
image.dpi = 300
result = exporter.exportToImage("out/parcels.png", image)
if result != QgsLayoutExporter.ExportResult.Success:
raise RuntimeError(f"image export failed with code {result}")
pdf = QgsLayoutExporter.PdfExportSettings()
pdf.dpi = 300
pdf.rasterizeWholeImage = False # keep the map as vectors
exporter.exportToPdf("out/parcels_outlines.pdf", pdf)
pdf.textRenderFormat = Qgis.TextRenderFormat.AlwaysText # and the text as text
exporter.exportToPdf("out/parcels.pdf", pdf)
svg = QgsLayoutExporter.SvgExportSettings()
svg.exportAsLayers = True # editable layers in Illustrator/Inkscape
exporter.exportToSvg("out/parcels.svg", svg)
for path in ("out/parcels.png", "out/parcels.pdf", "out/parcels.svg"):
print(f"{path:18} {Path(path).stat().st_size / 1e6:6.1f} MB")
print("PNG:", png_size("out/parcels.png"))
for path in ("out/parcels_outlines.pdf", "out/parcels.pdf"):
text_in_pdf = subprocess.run(["pdftotext", path, "-"], capture_output=True, text=True).stdout
print(f"title is selectable text in {path}:", "INSPIRE Index Polygons" in text_in_pdf)
shutil.rmtree("nowhere", ignore_errors=True)
print("into a folder that does not exist:",
QgsLayoutExporter.ExportResult(exporter.exportToImage("nowhere/parcels.png", image)).name,
"| folder created:", Path("nowhere").is_dir())
print("into a 'folder' that is a file: ",
QgsLayoutExporter.ExportResult(exporter.exportToImage("out/parcels.pdf/parcels.png", image)).name)
out/parcels.png 0.8 MB
out/parcels.pdf 5.0 MB
out/parcels.svg 18.7 MB
PNG: 3507 x 2480 px
title is selectable text in out/parcels_outlines.pdf: False
title is selectable text in out/parcels.pdf: True
into a folder that does not exist: Success | folder created: True
into a 'folder' that is a file: FileError
Two settings decide whether the PDF is usable. rasterizeWholeImage = False keeps the map as vectors, but it is not what makes text selectable: with the default text format the title was exported as outlines, and pdftotext found nothing. Setting textRenderFormat to AlwaysText embedded it as text.
Checking the return code matters too. The exporter reports failure by enum, not by exception. A missing folder was not a failure — the exporter created it — but a path whose "folder" was a file returned FileError, raised nothing, and wrote nothing.
Loop it with an atlas
atlas = layout.atlas()
atlas.setCoverageLayer(wards)
atlas.setEnabled(True)
atlas.setFilenameExpression("'ward_' || \"WD24CD\"")
map_item.setAtlasDriven(True)
map_item.setAtlasScalingMode(QgsLayoutItemMap.AtlasScalingMode.Auto)
map_item.setAtlasMargin(0.10) # 10% breathing room around each feature
title.setText("[% \"WD24NM\" %] — parcels by area") # evaluated per atlas feature
Path("out/atlas").mkdir(parents=True, exist_ok=True)
settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = 150
result, error = QgsLayoutExporter.exportToImage(atlas, "out/atlas/ward", "png", settings)
files = sorted(Path("out/atlas").glob("*.png"))
print(QgsLayoutExporter.ExportResult(result).name, error or "", "|", len(files), "files, e.g.",
[f.name for f in files[:2]])
Success | 15 files, e.g. ['ward_E05012632.png', 'ward_E05012633.png']
Note the last call is the static form of exportToImage, taking the atlas as an iterator and returning a result and an error message. Fifteen wards, fifteen correctly named PNGs, one function call — and the title on each sheet names its ward, because a layout label evaluates [% … %] expressions against the current atlas feature.
Exporting styled maps of Rutland
The data used on this page
Rutland's land use from Geofabrik's 1 January 2026 OpenStreetMap extract (© OpenStreetMap contributors, ODbL); HM Land Registry's INSPIRE Index Polygons for the county, with each parcel's area (© Crown copyright and database rights, HM Land Registry; OGL); and its 15 wards from the ONS December 2024 boundaries. QGIS 4.2 runs headless with QT_QPA_PLATFORM=offscreen.
import os
import subprocess
import sys
from pathlib import Path
import requests
from osgeo import gdal
gdal.UseExceptions()
for folder in ("data", "out", "styles"):
Path(folder).mkdir(exist_ok=True)
session = requests.Session()
if not Path("data/rutland.gpkg").exists():
Path("data/rutland.shp.zip").write_bytes(session.get(
"https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
gdal.VectorTranslate("data/rutland.gpkg", "/vsizip/data/rutland.shp.zip/gis_osm_landuse_a_free_1.shp",
layerName="landuse", dstSRS="EPSG:27700", reproject=True, selectFields=["fclass"])
Path("data/Rutland.zip").write_bytes(session.get(
"https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
gdal.VectorTranslate("data/rutland.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
accessMode="append", layerName="parcels", SQLDialect="SQLite",
SQLStatement="SELECT INSPIREID, ST_Area(geometry) AS area_m2, geometry FROM PREDEFINED")
Path("data/wards.geojson").write_bytes(session.get(
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
params={"where": "LAD24CD='E06000017'", "outFields": "WD24CD,WD24NM", "outSR": 27700, "f": "geojson"},
timeout=120).content)
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (Qgis, QgsApplication, QgsCategorizedSymbolRenderer, QgsFeatureRequest, QgsMapRendererSequentialJob,
QgsMapSettings, QgsProject, QgsRendererCategory, QgsSymbol, QgsVectorLayer)
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor, QImage
qgs = QgsApplication([], False)
qgs.initQgis()
def count(layer, expression):
return sum(1 for _ in layer.getFeatures(QgsFeatureRequest().setFilterExpression(expression)))
def png_size(path):
image = QImage(path)
return f"{image.width()} x {image.height()} px"
def drawn_pixels(layer, size=400):
"""Render one layer on white and count the pixels that are not white."""
settings = QgsMapSettings()
settings.setLayers([layer])
settings.setOutputSize(QSize(size, size))
settings.setExtent(layer.extent())
settings.setDestinationCrs(layer.crs())
settings.setBackgroundColor(QColor("white"))
job = QgsMapRendererSequentialJob(settings)
job.start()
job.waitForFinished()
image = job.renderedImage()
return sum(image.pixel(x, y) & 0xFFFFFF != 0xFFFFFF for x in range(size) for y in range(size))
# the "cartographer's" style: every land-use class, saved as a .qml
landuse = QgsVectorLayer("data/rutland.gpkg|layername=landuse", "landuse", "ogr")
classes = sorted(landuse.uniqueValues(landuse.fields().indexOf("fclass")))
categories = []
for i, value in enumerate(classes):
symbol = QgsSymbol.defaultSymbol(landuse.geometryType())
symbol.setColor(QColor.fromHsv(int(360 * i / len(classes)), 110, 225))
categories.append(QgsRendererCategory(value, symbol, value))
landuse.setRenderer(QgsCategorizedSymbolRenderer("fclass", categories))
landuse.saveNamedStyle("styles/landuse.qml")
print("QGIS", Qgis.QGIS_VERSION.split("-")[0], "|", landuse.featureCount(), "land-use polygons in",
len(classes), "classes |", QgsVectorLayer("data/rutland.gpkg|layername=parcels", "p", "ogr").featureCount(),
"parcels |", QgsVectorLayer("data/wards.geojson", "w", "ogr").featureCount(), "wards")
QGIS 4.2.2 | 2405 land-use polygons in 19 classes | 21253 parcels | 15 wards
Example 1: A styled export function you can call from a batch
TEMPLATE = Path("layouts/rutland.qpt")
def render_map(gpkg: str, layer_name: str, style: str, title: str, out_png: Path, dpi=150):
project = QgsProject.instance()
layer = QgsVectorLayer(f"{gpkg}|layername={layer_name}", layer_name, "ogr")
if not layer.isValid():
raise RuntimeError(f"could not load {gpkg}|{layer_name}")
message, ok = layer.loadNamedStyle(style)
if not ok:
raise RuntimeError(f"style failed: {message}")
layer.triggerRepaint()
project.addMapLayer(layer)
doc = QDomDocument()
doc.setContent(TEMPLATE.read_text())
layout = QgsPrintLayout(project)
layout.loadFromTemplate(doc, QgsReadWriteContext())
layout.atlas().setEnabled(False)
layout.itemById("title").setText(title)
mainmap = layout.itemById("mainmap")
mainmap.setLayers([layer])
mainmap.setExtent(layer.extent())
settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = dpi
out_png.parent.mkdir(parents=True, exist_ok=True)
code = QgsLayoutExporter(layout).exportToImage(str(out_png), settings)
if code != QgsLayoutExporter.ExportResult.Success:
raise RuntimeError(f"export failed ({code}) for {out_png}")
project.removeMapLayer(layer.id()) # keep the project clean between calls
return out_png
before = len(QgsProject.instance().mapLayers())
png = render_map("data/rutland.gpkg", "landuse", "styles/landuse.qml", "Rutland land use", Path("out/batch/landuse.png"))
print(png, png_size(str(png)), "| layers in project before and after:", before, len(QgsProject.instance().mapLayers()))
out/batch/landuse.png 1753 x 1240 px | layers in project before and after: 3 3
Dropping that into the loop from batch processing layers with PyQGIS turns a folder of results into a folder of maps.
Example 2: Graduated classes from this month's data
def apply_quantiles(layer, field, classes=5, ramp_name="Blues"):
renderer = QgsGraduatedSymbolRenderer(field)
renderer.setSourceSymbol(QgsSymbol.defaultSymbol(layer.geometryType()))
renderer.setSourceColorRamp(QgsStyle.defaultStyle().colorRamp(ramp_name))
renderer.setClassificationMethod(QgsClassificationQuantile())
renderer.updateClasses(layer, classes)
for r in renderer.ranges():
print(f"{r.lowerValue():>12,.0f} – {r.upperValue():>12,.0f} "
f"{count(layer, f'{field} >= {r.lowerValue()} AND {field} <= {r.upperValue()}'):>6,} parcels")
layer.setRenderer(renderer)
layer.triggerRepaint()
return [(r.lowerValue(), r.upperValue()) for r in renderer.ranges()]
breaks = apply_quantiles(parcels, "area_m2")
0 – 157 4,251 parcels
157 – 326 4,250 parcels
326 – 573 4,251 parcels
573 – 2,121 4,250 parcels
2,121 – 7,149,834 4,251 parcels
Returning the breaks lets the caller record them in the run's manifest — the difference between "the map looked different this month" and "the top quantile now starts at a different area". That is the same accounting instinct as a reproducible workflow manifest. Quantiles put the same number of parcels in each class, which here means the top class runs from a fifth of a hectare to a 715-hectare estate — a classification that says more about how many small plots Rutland has than about its land.
Example 3: Rendering a plain image with no layout
When you want a thumbnail rather than a map sheet, skip the layout entirely.
from qgis.core import QgsMapRendererParallelJob
settings = QgsMapSettings()
settings.setLayers([wards, landuse])
settings.setBackgroundColor(QColor("#ffffff"))
settings.setOutputSize(QSize(1200, 800))
settings.setExtent(wards.extent().buffered(wards.extent().width() * 0.05))
settings.setDestinationCrs(wards.crs())
job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
job.renderedImage().save("out/thumb.png")
print(png_size("out/thumb.png"))
1200 x 800 px
Faster and simpler than a layout, with no legend, no scale bar, and no page — exactly right for a preview image in a report.
Example 4: Checking a rendered output is not blank
An export that "succeeded" and produced a white rectangle is the classic silent failure.
from PIL import Image
def looks_blank(png_path, min_unique_colours=8):
with Image.open(png_path) as img:
colours = img.convert("RGB").getcolors(maxcolors=1_000_000)
return colours is not None and len(colours) < min_unique_colours
orphan = QgsVectorLayer("data/rutland.gpkg|layername=parcels", "not in the project", "ogr")
other_project = QgsProject() # the project the layout belongs to; the layer never joins it
empty_layout = QgsPrintLayout(other_project)
empty_layout.initializeDefaults()
lonely_map = QgsLayoutItemMap(empty_layout)
lonely_map.attemptResize(QgsLayoutSize(277, 190, MM))
lonely_map.setExtent(orphan.extent())
empty_layout.addLayoutItem(lonely_map)
code = QgsLayoutExporter(empty_layout).exportToImage("out/orphan.png", image)
print("layer never added to the project:", QgsLayoutExporter.ExportResult(code).name,
"| looks blank:", looks_blank("out/orphan.png"))
print("the parcels layout: looks blank:", looks_blank("out/parcels.png"))
layer never added to the project: Success | looks blank: True
the parcels layout: looks blank: False
The export returned Success and wrote a white page. The pixel check caught it — cheap, crude, and it catches the two real causes: an extent that missed the data, and a layer that was never added to the project.
Example 5: What a server without fonts does to labels
probe = '''
import os, sys
os.environ["QT_QPA_PLATFORM"] = "offscreen"
from qgis.core import *
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor, QFont, QFontDatabase
app = QgsApplication([], False); app.initQgis()
wards = QgsVectorLayer("data/wards.geojson", "wards", "ogr")
wards.setRenderer(QgsSingleSymbolRenderer(QgsFillSymbol.createSimple({"color": "white", "outline_style": "no"})))
fmt = QgsTextFormat(); fmt.setFont(QFont("DejaVu Sans")); fmt.setSize(12); fmt.setColor(QColor("black"))
labels = QgsPalLayerSettings(); labels.fieldName = "WD24NM"; labels.setFormat(fmt)
wards.setLabelsEnabled(True); wards.setLabeling(QgsVectorLayerSimpleLabeling(labels))
ms = QgsMapSettings(); ms.setLayers([wards]); ms.setOutputSize(QSize(800, 800)); ms.setExtent(wards.extent())
ms.setDestinationCrs(wards.crs()); ms.setBackgroundColor(QColor("white"))
job = QgsMapRendererSequentialJob(ms); job.start(); job.waitForFinished(); img = job.renderedImage()
img.save(sys.argv[1])
dark = sum(QColor(img.pixel(x, y)).lightness() < 100 for x in range(800) for y in range(800))
print(len(QFontDatabase.families()), dark)
app.exitQgis()
'''
Path("label_probe.py").write_text(probe)
Path("nofonts").mkdir(exist_ok=True)
Path("nofonts/fonts.conf").write_text(f'<?xml version="1.0"?><fontconfig><dir>{Path("nofonts").resolve()}</dir>'
f'<cachedir>{Path("nofonts").resolve()}</cachedir></fontconfig>')
for label, extra in (("system fonts", {}), ("no fonts", {"FONTCONFIG_FILE": str(Path("nofonts/fonts.conf").resolve())})):
run = subprocess.run([sys.executable, "label_probe.py", f"out/labels_{label.replace(' ', '_')}.png"],
capture_output=True, text=True, env=dict(os.environ, **extra))
families, dark = run.stdout.split()[-2:]
print(f"{label:13} {families:>5} font families, {int(dark):>6,} near-black pixels of label text")
system fonts 1679 font families, 4,475 near-black pixels of label text
no fonts 3 font families, 23,069 near-black pixels of label text
With no fonts for fontconfig to find, Qt fell back to three generic families with nothing behind them, and every ward name was drawn as a row of solid boxes — several times the ink of real text, and no error anywhere. A missing named font is not the same problem: Qt substitutes another family and the labels still read. Install a font package (fonts-dejavu-core is small) in any image that renders.
Symbols, renderers and layouts
The QGIS rendering model has three layers, and keeping them separate makes the API make sense. A symbol describes how one geometry is drawn: fill colour, stroke, marker size, opacity, and any number of stacked symbol layers. A renderer decides which symbol each feature gets: the same one for all of them, one per category, or one per numeric class. Labeling is entirely separate from both, which is why a layer can have perfect symbology and no labels. Almost every "how do I change the colour" question is really "which of those three am I holding", and the answer is that you fetch a symbol, change it, and hand it back to a renderer.
Layouts are a second, independent model. A QgsPrintLayout is a page with items on it, positioned in millimetres, and a QgsLayoutItemMap is a window onto the project's layers with its own extent, scale, and CRS. That separation is what makes atlases possible: the layout does not know or care which features it is showing, so an iterator can drive its extent feature by feature while everything else on the page stays fixed. It is also why linking matters — a legend or scale bar has to be told which map item it describes, because a layout can hold several.
The part that surprises people is that none of this needs a screen. Rendering goes through Qt's paint engine into an image buffer; no X server is involved, and QT_QPA_PLATFORM=offscreen is all this page used. The genuine server-side dependency is fonts: example 5 shows what a machine with none does to labels, and nothing reports it.
Finally, the argument for automating the last mile at all. A map produced by hand each month is a map whose classification, extent, and title were decided by whoever had time that day. A scripted map has its breaks recorded, its extent derived from the data, and its title generated from the run — so two months' maps are comparable, and a difference between them means the data changed. That is the same reason to script the analysis, applied to the artefact that people actually look at.
Fonts, DPI, scale and return codes
Layers must be in the project
QgsLayoutItemMap renders the project's layers, or a specific list if you call setLayers([...]). A layer that was loaded but never added to QgsProject.instance() renders as nothing at all — example 4 exported it as a white page with a Success code.
Missing fonts on a server
Labels become boxes when fontconfig can find no fonts at all, as example 5 showed. A font that is merely not installed is substituted instead, so the labels read but change their look between machines — name a font you know is present.
DPI is not resolution
settings.dpi = 300 scales the whole page: an A4 landscape page at 300 DPI came out at 3507 × 2480 pixels. Setting DPI does not change the map scale or which features are drawn, but it does change how big a label or a hairline stroke looks relative to the page. Check the result at the target size rather than assuming.
Scale-dependent visibility
A layer with setScaleBasedVisibility(True) may be invisible at the layout's scale even though it is in the project. If an export is missing a layer that is definitely loaded, this is the first thing to check.
The exporter returns a code, not an exception
exportToImage and friends return a QgsLayoutExporter.ExportResult; Success is the good one. A folder that does not exist returned FileError and no traceback. Always compare against Success.
Clean up layers between iterations
Adding a layer to the project on every loop iteration accumulates them, slows rendering, and eventually shows the wrong data on a map. Call project.removeMapLayer(layer.id()) when a map is done, as render_map does, or build a fresh project per iteration.
.qml styles are schema-dependent
A style that classifies on fclass applied without complaint to the parcels, which have no such field, and drew nothing. Assert that the fields the style needs exist before applying it — the schema validation habit, at the cartography boundary.
Internal links
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- How to Run a PyQGIS Script Headless Without Opening QGIS
- How to Batch Process Layers in QGIS with PyQGIS
- How to Run QGIS Processing Algorithms from Python
- How to Create a Choropleth Map in Python with GeoPandas
- How to Save a Map as an Image in Python with Matplotlib
- How to Move Data Between QGIS and GeoPandas
FAQ
Can I export QGIS map layouts without a display?
Yes. Set QT_QPA_PLATFORM=offscreen and rendering goes through Qt's paint engine into memory — no X server, no Xvfb. The output is identical to a desktop export. The one thing to install on a minimal server is a font package, or labels will silently disappear.
How do I apply a .qml style file from Python?
message, ok = layer.loadNamedStyle("styles/parcels.qml"), then check ok and call layer.triggerRepaint(). Note that a style applies "successfully" even when the fields it classifies on are missing, rendering everything in the fallback symbol — so check the fields exist first if the schema can drift.
What is the difference between setting an extent and setting a scale?
map_item.setExtent(rect) fits the map to a bounding box, so each sheet has its own scale. map_item.setScale(25000) fixes the scale around the current centre, so every sheet is comparable but some features may fall outside the frame. For a series of district maps, a fixed scale — or an atlas with a margin — usually produces the more honest set.
Why is my exported map blank?
Three usual causes, in order: the layer was never added to QgsProject.instance(); the map item's extent does not cover the data; or the layer has scale-based visibility that hides it at the layout's scale. A crude pixel check on the output catches all three before the file reaches anyone.
How do I produce one map per feature?
Use an atlas: set the coverage layer, enable it, set a filename expression, turn on setAtlasDriven(True) for the map item, and call the static QgsLayoutExporter.exportToImage(atlas, folder, "png", settings). Labels in the layout can reference the current feature with [% "field_name" %], so titles are generated too.
Should I build layouts in code or load a template?
Load a template for anything with more than a few items. Design it once in the layout designer, give every item an id in its properties, save as .qpt, and set the variable parts from Python with layout.itemById(). Build in code only for simple, generated pages where a designer would add nothing.
Can I do this with matplotlib instead?
For charts and quick figures, yes — and saving a map as an image with matplotlib covers that route. Choose QGIS layouts when you need cartographic output: legends that track the map, scale bars, north arrows, multi-page atlases, print-ready PDFs with selectable text, and styles a cartographer authored in the GUI.
A headless project has no CRS until you give it one
QGIS Desktop sets a new project's CRS from the first layer added. A script's project does not:
fresh = QgsProject()
fresh.addMapLayer(QgsVectorLayer("data/rutland.gpkg|layername=parcels", "Parcels", "ogr"))
sheet = QgsPrintLayout(fresh)
sheet.initializeDefaults()
frame = QgsLayoutItemMap(sheet)
frame.attemptResize(QgsLayoutSize(200, 175, MM))
sheet.addLayoutItem(frame)
frame.setExtent(fresh.mapLayersByName("Parcels")[0].extent())
print(f"project CRS {fresh.crs().authid() or '(none)'}: map scale 1:{frame.scale():,.0f}")
fresh.setCrs(fresh.mapLayersByName("Parcels")[0].crs())
frame.setExtent(fresh.mapLayersByName("Parcels")[0].extent())
print(f"project CRS {fresh.crs().authid()}: map scale 1:{frame.scale():,.0f}")
project CRS (none): map scale 1:10,409,243,457
project CRS EPSG:27700: map scale 1:139,389
With no CRS the map item cannot know its extent is in metres, and it reported a scale of one to ten billion for a county-sized map. The drawing itself still came out, which is what makes this easy to miss until a scale bar or a fixed-scale atlas depends on it. Set the project CRS — or the map item's, with setCrs() — before sizing anything.
The page's QGIS session ends here:
QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed