How to Style Layers and Export Map Layouts from PyQGIS

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 forty-three district maps 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.

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 .qml exists 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, missing labels on a server with no fonts.
  • Forty-three district maps are forty-three manual exports, and someone always mislabels one.
  • The whole thing needs a display β€” or so it seems, until you learn otherwise.

The goal: a script that loads today's data, applies a saved style, positions a map on a layout at a known scale, and writes a 300 DPI PNG and a PDF β€” headlessly.

Quick answer

Styling is a renderer on the layer; export is a layout plus an exporter.

From layer to image: load layer, apply renderer, add to project, place on a layout map item, export with settings.
Five objects between a GeoPackage and a printable PDF, and each has exactly one job.
from qgis.core import (
    QgsProject, QgsVectorLayer, QgsPrintLayout, QgsLayoutItemMap,
    QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes, QgsLayoutExporter,
)

project = QgsProject.instance()

layer = QgsVectorLayer("out/parcels.gpkg|layername=parcels", "Parcels", "ogr")
layer.loadNamedStyle("styles/parcels.qml")        # a style saved from the GUI
layer.triggerRepaint()
project.addMapLayer(layer)

layout = QgsPrintLayout(project)
layout.initializeDefaults()                       # one A4 landscape page

map_item = QgsLayoutItemMap(layout)
map_item.attemptMove(QgsLayoutPoint(10, 10, QgsUnitTypes.LayoutMillimeters))
map_item.attemptResize(QgsLayoutSize(277, 180, QgsUnitTypes.LayoutMillimeters))
map_item.setExtent(layer.extent())
layout.addLayoutItem(map_item)

settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = 300
QgsLayoutExporter(layout).exportToImage("out/parcels.png", settings)

Run it with the headless bootstrap from running PyQGIS headless β€” QT_QPA_PLATFORM=offscreen is enough; no X server is required.

Step-by-step solution

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.

message, ok = layer.loadNamedStyle("styles/parcels.qml")
if not ok:
    raise RuntimeError(f"style not applied: {message}")
layer.triggerRepaint()

Save one from a styled layer with layer.saveNamedStyle("styles/parcels.qml"). 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. A .qml built for last quarter's schema applies "successfully" to a layer without the classified field and renders everything in the fallback symbol.

Build a renderer in code when the classes are data-driven

Three renderer types β€” single symbol, categorized, graduated β€” with what each needs and when to use it.
The renderer decides how features become symbols; everything else is the symbol's own settings.

Single symbol β€” one look for every feature:

from qgis.core import QgsSymbol, QgsSingleSymbolRenderer
from qgis.PyQt.QtGui import QColor

symbol = QgsSymbol.defaultSymbol(layer.geometryType())
symbol.setColor(QColor("#0ea5e9"))
symbol.setOpacity(0.85)
symbol.symbolLayer(0).setStrokeColor(QColor("#1a3a6b"))
layer.setRenderer(QgsSingleSymbolRenderer(symbol))
layer.triggerRepaint()

Categorized β€” one symbol per distinct value:

from qgis.core import QgsRendererCategory, QgsCategorizedSymbolRenderer

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("land_use", categories))
layer.triggerRepaint()

Graduated β€” classes computed from a numeric field:

from qgis.core import QgsGraduatedSymbolRenderer, QgsStyle

ramp = QgsStyle.defaultStyle().colorRamp("Blues")
renderer = QgsGraduatedSymbolRenderer.createRenderer(
    layer, "area_m2", 5,
    QgsGraduatedSymbolRenderer.Quantile,
    QgsSymbol.defaultSymbol(layer.geometryType()),
    ramp,
)
layer.setRenderer(renderer)
layer.triggerRepaint()

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.

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

text = QgsTextFormat()
text.setFont(QFont("DejaVu Sans", 9))
text.setColor(QColor("#1e293b"))

settings = QgsPalLayerSettings()
settings.fieldName = "parcel_id"
settings.setFormat(text)
settings.placement = QgsPalLayerSettings.OverPoint     # or Line, AroundPoint, …

layer.setLabelsEnabled(True)
layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
layer.triggerRepaint()

fieldName also accepts an expression when you set settings.isExpression = True β€” concat("name", '\n', format_number("area_m2", 0), ' mΒ²') and similar.

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 (
    QgsPrintLayout, QgsLayoutItemMap, QgsLayoutItemLabel, QgsLayoutItemLegend,
    QgsLayoutItemScaleBar, QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes,
)

MM = QgsUnitTypes.LayoutMillimeters

layout = QgsPrintLayout(project)
layout.initializeDefaults()
layout.setName("District report")

map_item = QgsLayoutItemMap(layout)
map_item.attemptMove(QgsLayoutPoint(10, 25, MM))
map_item.attemptResize(QgsLayoutSize(200, 160, MM))
map_item.setExtent(layer.extent())
map_item.setFrameEnabled(True)
layout.addLayoutItem(map_item)

title = QgsLayoutItemLabel(layout)
title.setText("Parcels by area β€” August 2026")
title.setFont(QFont("DejaVu Sans", 16))
title.adjustSizeToText()
title.attemptMove(QgsLayoutPoint(10, 8, MM))
layout.addLayoutItem(title)

legend = QgsLayoutItemLegend(layout)
legend.setTitle("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)

setLinkedMap is what makes the legend and scale bar track the map β€” without it the legend is empty and the bar shows a meaningless scale.

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:

from qgis.PyQt.QtXml import QDomDocument
from qgis.core import QgsReadWriteContext
from pathlib import Path

doc = QDomDocument()
doc.setContent(Path("layouts/district.qpt").read_text())

layout = QgsPrintLayout(project)
layout.loadFromTemplate(doc, QgsReadWriteContext())

title = layout.itemById("title")           # ids set in the GUI's item properties
title.setText("Parcels by area β€” August 2026")

map_item = layout.itemById("mainmap")
map_item.setExtent(layer.extent())

Setting an id on each item in the layout designer 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

from qgis.core import QgsLayoutExporter

exporter = QgsLayoutExporter(layout)

image = QgsLayoutExporter.ImageExportSettings()
image.dpi = 300
result = exporter.exportToImage("out/district.png", image)
if result != QgsLayoutExporter.Success:
    raise RuntimeError(f"image export failed with code {result}")

pdf = QgsLayoutExporter.PdfExportSettings()
pdf.dpi = 300
pdf.rasterizeWholeImage = False        # keep vector text selectable
exporter.exportToPdf("out/district.pdf", pdf)

svg = QgsLayoutExporter.SvgExportSettings()
svg.exportAsLayers = True              # editable layers in Illustrator/Inkscape
exporter.exportToSvg("out/district.svg", svg)

Checking the return code matters. The exporter reports failure by enum, not by exception, so an unchecked export silently produces nothing when the output directory does not exist.

Loop it with an atlas

Atlas export steps: choose a coverage layer, enable the atlas, set the filename expression, export one image per feature.
One layout, one coverage layer, one file per feature β€” with the extent following each feature.
districts = QgsVectorLayer("data/districts.gpkg|layername=districts", "Districts", "ogr")
project.addMapLayer(districts)

atlas = layout.atlas()
atlas.setCoverageLayer(districts)
atlas.setEnabled(True)
atlas.setFilenameExpression("'district_' || \"district_code\"")
map_item.setAtlasDriven(True)
map_item.setAtlasScalingMode(QgsLayoutItemMap.Auto)
map_item.setAtlasMargin(0.10)          # 10% breathing room around each feature

settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = 200
QgsLayoutExporter.exportToImage(atlas, "out/atlas/", "png", settings)

Note the last call is the static form of exportToImage, taking the atlas as an iterator. Forty-three districts, forty-three correctly named PNGs, one function call.

Code examples

Example 1: A styled export function you can call from a batch

"""maps.py β€” produce one map image from a layer and a template."""
from pathlib import Path

from qgis.core import (
    QgsProject, QgsVectorLayer, QgsPrintLayout, QgsReadWriteContext, QgsLayoutExporter,
)
from qgis.PyQt.QtXml import QDomDocument

TEMPLATE = Path("layouts/district.qpt")

def render_map(gpkg: str, layer_name: str, style: str, title: str, out_png: Path, dpi=300):
    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.itemById("title").setText(title)
    layout.itemById("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.Success:
        raise RuntimeError(f"export failed ({code}) for {out_png}")

    project.removeMapLayer(layer.id())     # keep the project clean between calls
    return out_png

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

from qgis.core import QgsGraduatedSymbolRenderer, QgsStyle, QgsSymbol

def apply_quantiles(layer, field, classes=5, ramp_name="Blues"):
    ramp = QgsStyle.defaultStyle().colorRamp(ramp_name)
    renderer = QgsGraduatedSymbolRenderer.createRenderer(
        layer, field, classes,
        QgsGraduatedSymbolRenderer.Quantile,
        QgsSymbol.defaultSymbol(layer.geometryType()),
        ramp,
    )
    for r in renderer.ranges():
        print(f"{r.lowerValue():>12,.0f} – {r.upperValue():>12,.0f}  {r.label()}")
    layer.setRenderer(renderer)
    layer.triggerRepaint()
    return [(r.lowerValue(), r.upperValue()) for r in renderer.ranges()]

Returning the breaks lets the caller record them in the run's manifest β€” the difference between "the map looked different this month" and "the quantile breaks moved from 480 to 610". That is the same accounting instinct as a reproducible workflow manifest.

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 QgsMapSettings, QgsMapRendererParallelJob
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor

settings = QgsMapSettings()
settings.setLayers([layer])
settings.setBackgroundColor(QColor("#ffffff"))
settings.setOutputSize(QSize(1200, 800))
settings.setExtent(layer.extent().buffered(layer.extent().width() * 0.05))
settings.setDestinationCrs(layer.crs())

job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
job.renderedImage().save("out/thumb.png")

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: Atlas with per-feature title text

title = layout.itemById("title")
title.setText("[% \"district_name\" %] β€” parcels by area")   # layout expression

Layout labels evaluate expressions in [% … %], and during an atlas run the expression context is the current coverage feature. One template, forty-three correctly titled sheets, no Python in the loop.

Example 5: 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        # pip install pillow

def assert_not_blank(png_path, min_unique_colours=8):
    with Image.open(png_path) as img:
        colours = img.convert("RGB").getcolors(maxcolors=1_000_000)
    if colours is None:
        return                       # very colourful: definitely not blank
    if len(colours) < min_unique_colours:
        raise AssertionError(f"{png_path} looks blank ({len(colours)} colours)")

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.

Explanation

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; the X server was never involved. With QT_QPA_PLATFORM=offscreen a server produces byte-identical output to a workstation, which means map production belongs in the nightly job alongside the analysis rather than in someone's morning. The one genuine server-side dependency is fonts: a minimal container with no fonts installed renders labels as boxes or drops them entirely, without an error, so install a font package in any image that renders.

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.

Edge cases or notes

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, with no error β€” the most common cause of a blank export.

Missing fonts on a server

Labels vanish or become boxes when the font a style names is not installed. Install fonts-dejavu (or your corporate font) in the container, and prefer naming a font you know is present over relying on a system default that differs between machines.

DPI is not resolution

settings.dpi = 300 scales the whole page: an A4 landscape at 300 DPI is about 3508 Γ— 2480 pixels. Setting DPI does not change the map scale or which features are drawn, but it does change what scale-dependent visibility and label sizes look like 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 an enum; QgsLayoutExporter.Success is the good one. A missing output directory, a locked file, or an invalid path produce a failure code 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, or build a fresh project per iteration.

.qml styles are schema-dependent

A style that classifies on area_m2 applies without complaint to a layer that has no such field, and everything renders in the fallback symbol. Assert that the fields the style needs exist before applying it β€” the schema validation habit, at the cartography boundary.

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.