Automating Print Layouts and Map Export
Generate maps programmatically with the QGIS layout engine — QgsPrintLayout, QgsLayoutExporter to PDF and PNG, atlas map series, and templating layouts for…
Producing a single map by hand in the QGIS layout designer is quick. Producing four hundred of them — one per council ward, one per survey plot, one per monitoring station, each with a consistent legend, title and scale bar — is a different problem entirely. Manual export does not scale, it is not reproducible, and it cannot be wired into a nightly pipeline. The QGIS layout engine is fully scriptable, and that is the escape hatch: everything the designer does with mouse clicks maps onto a public PyQGIS API. This page, part of the Plugin Development & UI Integration in QGIS guide, shows how to construct print layouts in code, populate them with map, legend, label and scale-bar items, and drive QgsLayoutExporter to emit PDF, PNG and SVG at production scale.
By the end you will have a reusable pattern for turning a styled project into repeatable cartographic output, plus the atlas controller for generating one page per feature and template loading for handing design control back to cartographers.
Prerequisites Checklist
Before scripting the layout engine, confirm your environment and inputs:
- QGIS 3.28+ (LTR recommended): The
QgsLayout/QgsPrintLayoutAPI andQgsLayoutExporterstatus enums used below are stable from 3.28 onward. Earlier 3.x releases work but differ in minor signatures. - Python 3.8+: Required for the type hints in every sample here.
- A project with styled layers: The layout engine renders whatever styling the layers already carry. Symbology, labelling and layer order are read from the live
QgsProject, so style your layers first — the layout does not restyle anything. - A live
QgsProjectinstance: Layouts belong to a project. In a plugin you haveQgsProject.instance(); in a headless script you load a.qgz/.qgsfile. See working with QgsProject and layer registry for safe project and layer lifecycle handling. - Known extents: Every map item needs an explicit extent (a
QgsRectanglein the map CRS). Have the extents you intend to render ready, or compute them from layer bounds.
The layout engine is a rendering layer, not a data-cleaning layer. Invalid geometries, missing CRS on a layer, or unstyled data will surface as visual defects in the output rather than exceptions during export.
Architecture and Internals
A layout is a tree of items owned by a project. Understanding that containment hierarchy is the whole game — once you can name the objects, the API reads predictably.
At the root, QgsProject owns a single QgsLayoutManager, retrieved with project.layoutManager(). The manager holds any number of named layouts. QgsLayout is the abstract base; QgsPrintLayout is the concrete paged variant you almost always want — it adds page collection, guides and an atlas controller. (The other subclass, QgsReport, composes multiple layouts into a document and is out of scope here.)
A QgsPrintLayout contains child items, all descending from QgsLayoutItem: QgsLayoutItemMap (the rendered map window), QgsLayoutItemLegend, QgsLayoutItemLabel, QgsLayoutItemScaleBar, QgsLayoutItemPicture, and more. Each item has a position and size expressed in layout units, and is added to the layout with layout.addLayoutItem(item). Finally, QgsLayoutExporter takes a finished layout and writes it to a file format.
Units, positions and the frame model
Layout geometry is measured, not pixel-based. Positions use QgsLayoutPoint and sizes use QgsLayoutSize, each carrying a QgsUnitTypes.LayoutUnit — usually QgsUnitTypes.LayoutMillimeters. A QgsLayoutMeasurement pairs a number with a unit, so QgsLayoutMeasurement(5, QgsUnitTypes.LayoutMillimeters) is an unambiguous 5 mm. This separation from output pixels is what lets the same layout export cleanly at 96 DPI for the web and 300 DPI for print — the geometry is fixed in millimetres and DPI only affects rasterisation.
Some items (notably the legend and the HTML/label items) use a frame: a resizable rectangle that can auto-fit its content. QgsLayoutItemLegend.setResizeToContents(True) makes the legend grow to fit its entries rather than clipping them.
Reference maps and the atlas controller
When you have several map items, one is designated the reference map via layout.setReferenceMap(map_item). The reference map drives the layout’s overall scale and is what the atlas iterates. The atlas controller — layout.atlas() — binds the layout to a coverage layer: it steps through that layer’s features, moves the reference map to each feature’s extent, and exports one page per feature. Atlas is covered in depth in generating an atlas map series programmatically.
Step-by-Step Implementation
The following builds a complete A4 landscape layout from a styled layer, adds the four core cartographic items, and exports it. Every function is drop-in and typed.
Step 1 — Create and register the layout
A layout must be added to the project’s layout manager under a unique name, replacing any previous layout of the same name so repeated runs stay idempotent.
from qgis.core import QgsProject, QgsPrintLayout
def create_print_layout(project: QgsProject, name: str) -> QgsPrintLayout:
"""
Create a fresh QgsPrintLayout registered with the project's layout manager.
Any existing layout of the same name is removed first so the function is
idempotent across repeated runs (important in batch pipelines).
Args:
project: The open QgsProject that will own the layout.
name: A unique layout name within the project.
Returns:
A registered QgsPrintLayout with one default A4 page.
"""
manager = project.layoutManager()
existing = manager.layoutByName(name)
if existing is not None:
manager.removeLayout(existing)
layout = QgsPrintLayout(project)
layout.initializeDefaults() # adds a single default A4 portrait page
layout.setName(name)
manager.addLayout(layout)
return layout
initializeDefaults() is essential — without it the layout has no page collection and every item you add lands on a zero-size canvas. To switch to A4 landscape, resize the page after initialisation via layout.pageCollection().page(0).setPageSize("A4", QgsLayoutItemPage.Landscape).
Step 2 — Add a map item bound to an extent
The map item is the heart of the layout. It needs an explicit position, size, the layers to render, and an extent in the map CRS.
from qgis.core import (
QgsLayoutItemMap,
QgsLayoutPoint,
QgsLayoutSize,
QgsMapLayer,
QgsPrintLayout,
QgsRectangle,
QgsUnitTypes,
)
def add_map_item(
layout: QgsPrintLayout,
layers: list[QgsMapLayer],
extent: QgsRectangle,
) -> QgsLayoutItemMap:
"""
Add a QgsLayoutItemMap to the layout, positioned and bound to an extent.
Args:
layout: The target QgsPrintLayout.
layers: Layers to render, in draw order (index 0 draws on top).
extent: The map window in the layers' CRS.
Returns:
The configured QgsLayoutItemMap, already added to the layout.
"""
map_item = QgsLayoutItemMap(layout)
mm = QgsUnitTypes.LayoutMillimeters
# Position 10 mm from the top-left, sized 180 x 190 mm (fits A4 landscape).
map_item.attemptMove(QgsLayoutPoint(10, 10, mm))
map_item.attemptResize(QgsLayoutSize(180, 190, mm))
map_item.setLayers(layers) # explicit layer set — do not rely on project order
map_item.setExtent(extent) # extent must overlap the data or the map is blank
map_item.setFrameEnabled(True)
layout.addLayoutItem(map_item)
layout.setReferenceMap(map_item) # this map drives layout scale and atlas
return map_item
Two details are load-bearing. setLayers() pins an explicit layer list to the item; if you omit it the map falls back to the current project layer set, which is fragile in a headless batch. And setExtent() must receive a rectangle that actually contains features — the single most common cause of a blank output is an extent in the wrong CRS or a default zero-extent.
Step 3 — Add legend, title and scale bar
Supporting items make a map legible. The legend and scale bar should be linked to the map item so they stay consistent with what the map shows.
from qgis.core import (
QgsLayoutItemLabel,
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsLayoutItemScaleBar,
QgsLayoutPoint,
QgsPrintLayout,
QgsUnitTypes,
)
from qgis.PyQt.QtGui import QFont
def add_supporting_items(
layout: QgsPrintLayout,
map_item: QgsLayoutItemMap,
title: str,
) -> None:
"""
Add a title label, a legend and a scale bar linked to `map_item`.
Args:
layout: The target print layout.
map_item: The reference map the legend and scale bar follow.
title: Text for the title label.
"""
mm = QgsUnitTypes.LayoutMillimeters
# Title label
label = QgsLayoutItemLabel(layout)
label.setText(title)
label.setFont(QFont("Sans", 16))
label.adjustSizeToText()
label.attemptMove(QgsLayoutPoint(200, 12, mm))
layout.addLayoutItem(label)
# Legend, linked to the map so it reflects only that map's layers
legend = QgsLayoutItemLegend(layout)
legend.setTitle("Legend")
legend.setLinkedMap(map_item)
legend.setResizeToContents(True)
legend.setAutoUpdateModel(True)
legend.attemptMove(QgsLayoutPoint(200, 40, mm))
layout.addLayoutItem(legend)
# Scale bar, linked to the map so it tracks the map's scale
scalebar = QgsLayoutItemScaleBar(layout)
scalebar.setLinkedMap(map_item)
scalebar.applyDefaultSize()
scalebar.setStyle("Single Box")
scalebar.attemptMove(QgsLayoutPoint(200, 180, mm))
layout.addLayoutItem(scalebar)
setLinkedMap() is the key call on both the legend and the scale bar: it wires them to the reference map so a change of extent or scale propagates automatically. applyDefaultSize() on the scale bar picks a sensible segment size for the current map scale — always call it after linking the map and setting the extent, or it computes against the wrong scale.
Step 4 — Export with QgsLayoutExporter
With the layout assembled, exporting is a couple of lines. QgsLayoutExporter returns a status enum you must check.
from pathlib import Path
from qgis.core import QgsLayoutExporter, QgsPrintLayout
def export_layout_png(layout: QgsPrintLayout, out_path: Path, dpi: int = 300) -> None:
"""
Export a print layout to a PNG image at the requested DPI.
Args:
layout: The fully assembled QgsPrintLayout.
out_path: Destination PNG file path.
dpi: Output resolution; 300 for print, 96 for screen.
Raises:
RuntimeError: If the exporter returns a non-Success status.
"""
exporter = QgsLayoutExporter(layout)
settings = QgsLayoutExporter.ImageExportSettings()
settings.dpi = dpi
result = exporter.exportToImage(str(out_path), settings)
if result != QgsLayoutExporter.Success:
raise RuntimeError(f"PNG export failed with status {result} for {out_path}")
The PDF path is nearly identical but uses PdfExportSettings and exportToPdf(), with extra controls for vector versus raster output, georeferencing and metadata — see exporting a print layout to PDF with QgsLayoutExporter for the full settings surface. Chaining the four functions gives a complete pipeline:
from pathlib import Path
from qgis.core import QgsProject
def build_and_export(project: QgsProject, layer_id: str, out_dir: Path) -> None:
"""Assemble a standard map sheet from one layer and export it to PNG."""
layer = project.mapLayer(layer_id)
if layer is None or not layer.isValid():
raise ValueError(f"Layer '{layer_id}' is missing or invalid.")
layout = create_print_layout(project, name="Standard Sheet")
map_item = add_map_item(layout, [layer], layer.extent())
add_supporting_items(layout, map_item, title=layer.name())
out_dir.mkdir(parents=True, exist_ok=True)
export_layout_png(layout, out_dir / f"{layer.name()}.png", dpi=300)
Advanced Patterns
Exporting to PDF with full settings
PDF is the default deliverable for cartographic output because it preserves vector geometry and text. QgsLayoutExporter.PdfExportSettings exposes rasterizeWholeImage, forceVectorOutput, exportMetadata and appendGeoreference. Force vector output keeps labels crisp and file sizes small; rasterising is only needed when blend modes or effects would otherwise be flattened incorrectly. Geospatial PDFs (appendGeoreference = True) embed CRS and map extent so the file can be re-opened as a georeferenced document. The companion page exporting a print layout to PDF with QgsLayoutExporter walks through each flag and the trade-offs.
Atlas map series
An atlas turns one layout into many pages, one per feature of a coverage layer. Enable it via layout.atlas(), set the coverage layer, and let QgsLayoutExporter.exportToPdf() (with an atlas-aware overload) or a manual atlas.beginRender() / atlas.next() loop drive the iteration. The reference map follows each feature’s geometry, and text items can use @atlas_pagename and other expression variables to label each sheet dynamically. This is the mechanism behind “one map per ward” or “one map per survey plot” deliverables; generating an atlas map series programmatically covers the controller loop, filtering and per-feature naming end to end.
Loading .qpt templates and substituting data
When cartographers design a layout in the QGIS designer, they export it as a .qpt template — an XML document. Rather than rebuilding that design in code, load it and swap in data. Read the file into a QDomDocument, call layout.loadFromTemplate(), then look up items by ID to update them.
from pathlib import Path
from qgis.core import QgsPrintLayout, QgsProject, QgsReadWriteContext
from qgis.PyQt.QtCore import QFile, QIODevice
from qgis.PyQt.QtXml import QDomDocument
def layout_from_template(
project: QgsProject,
template_path: Path,
name: str,
) -> QgsPrintLayout:
"""
Build a print layout from a .qpt template file and register it.
Args:
project: The owning project.
template_path: Path to a .qpt template exported from the designer.
name: Name to register the resulting layout under.
Returns:
The loaded, registered QgsPrintLayout.
Raises:
IOError: If the template file cannot be read.
"""
document = QDomDocument()
template_file = QFile(str(template_path))
if not template_file.open(QIODevice.ReadOnly | QIODevice.Text):
raise IOError(f"Cannot open template: {template_path}")
document.setContent(template_file.read())
template_file.close()
layout = QgsPrintLayout(project)
layout.initializeDefaults()
layout.loadFromTemplate(document, QgsReadWriteContext())
layout.setName(name)
project.layoutManager().addLayout(layout)
# Update the title label placed in the designer with id "title_label"
title = layout.itemById("title_label")
if title is not None:
title.setText("Generated 2026")
return layout
layout.itemById("...") retrieves items by the ID assigned in the designer’s item properties panel — the contract that lets code and design stay decoupled. Give every item a stable ID in the template and your script never has to know its position.
Pitfalls and Debugging
-
Running export off the main thread: Layout rendering walks the live map rendering stack and is not safe to run concurrently with the UI touching the same layers. For long batch jobs, keep the UI responsive by scheduling work through asynchronous task execution with QgsTask, but perform the actual
QgsLayoutExportercall on the main thread — or against a cloned project whose layers no other thread touches. A rawQgsLayoutExportercall inside a backgroundQThreadsharing UI layers is a common source of hard crashes. -
Missing CRS on the map item render context: If a layer has no CRS, or the map item’s CRS differs from the layer CRS, features render in the wrong place or not at all. Confirm
layer.crs().isValid()and set the map’s CRS explicitly withmap_item.setCrs(layer.crs())when mixing sources. -
DPI and units confusion: Layout geometry is in millimetres; only the exporter’s
dpisetting controls rasterisation. Setting a huge item size to “get higher resolution” is wrong — it enlarges the map on the page. To increase output resolution, raisesettings.dpi, not the item dimensions. A 300 DPI export of an A4 page is roughly 3508 × 2480 px; expect memory to scale with the square of DPI. -
Blank maps from the wrong extent: By far the most frequent failure. The map item defaults to a zero or arbitrary extent. Always call
setExtent()with a rectangle you have verified overlaps the data —layer.extent()is a safe starting point. If the layer and map CRS differ, transform the extent first. A blank page with a visible frame is the signature of this bug. -
Legend or scale bar out of sync: If the legend shows the wrong layers or the scale bar reads the wrong scale, you forgot
setLinkedMap(), or you calledapplyDefaultSize()before setting the map extent. Link first, set the extent, then size the scale bar. -
Layout not appearing in a re-run: Adding a second layout with a name that already exists silently creates a duplicate. Remove the existing layout by name first (as
create_print_layoutdoes) to keep batch runs idempotent.
Conclusion
The QGIS layout engine turns cartographic output from a manual chore into a reproducible function call. The mental model is a containment tree — a project owns a layout manager, which owns print layouts, which own map, legend, label and scale-bar items — and QgsLayoutExporter renders that tree to PDF, PNG or SVG. Build layouts in code when the structure is dynamic, load .qpt templates when cartographers own the design, and reach for the atlas controller when you need one page per feature. Align CRS on every map item, pin explicit layers and extents to avoid blank output, drive DPI through the exporter rather than item size, and keep long batch exports on the main thread while scheduling around them with tasks. With these patterns you can generate hundreds of consistent, print-ready sheets from a single styled project on a schedule.
Related
- Plugin Development & UI Integration in QGIS — the parent guide covering the full plugin and UI automation stack
- Exporting a Print Layout to PDF with QgsLayoutExporter — the complete PdfExportSettings surface and vector-versus-raster trade-offs
- Generating an Atlas Map Series Programmatically — the atlas controller loop for one page per feature
- Custom Map Canvas Overlays and Rendering in PyQGIS — sibling guide on programmatic rendering on the live map canvas
- Asynchronous Task Execution with QgsTask — keeping the UI responsive while long batch exports run