Automated Map Layout Generation with PyQGIS
Automated map layout generation is where a spatial pipeline stops being data and starts being a deliverable. By replacing manual cartographic composition with script-driven layout creation, teams produce consistent, publication-ready maps at scale — the same margins, the same legend, the same fonts on every sheet — without touching the QGIS print layout dialog. Within Spatial Data Processing & Automation with PyQGIS, layout automation is the delivery layer: it takes the outputs of vector data manipulation and raster analysis workflows and turns them into finished cartography.
This guide walks the full PyQGIS layout object model end to end: building a QgsPrintLayout, adding and anchoring map frames, legends, scale bars and expression-driven labels, exporting to PDF, PNG and SVG, and wrapping the whole thing in a loop for batch processing. When a single template needs to fan out across many features rather than many layers, the atlas map series approach extends the same object model — this page is the foundation that guide builds on.
Prerequisites and the layout object model
Before automating cartography, make sure the environment and the input data are ready, and that you understand how the layout classes fit together.
- QGIS 3.34 LTR (Python 3.12) recommended. The
QgsPrintLayoutandQgsLayoutExporterAPIs used here are stable across the 3.28 LTR and 3.40/3.44 lines; version-specific differences are called out inline. - A running QGIS context. Use the QGIS Python Console for interactive testing, or initialize a standalone application with
QgsApplication.initQgis()for headless execution. Automated scripts must never assume a GUI canvas exists — they manage project state explicitly. - Clean, validated source data. Layouts render whatever is in the layer, so topology errors, missing geometries and undefined coordinate systems surface directly on the map. Validate vector inputs through vector data manipulation pipelines and confirm tiling, compression and projection alignment on rasters before ingestion.
- A single, deliberate CRS. Mismatched project and layer CRSs are the most common cause of distorted frames; standardise projections during ingestion using the patterns in coordinate reference systems in PyQGIS.
The object model is small once you see it. A QgsPrintLayout is a container bound to a QgsProject. Into it you add QgsLayoutItem subclasses — QgsLayoutItemMap for the viewport, plus QgsLayoutItemLegend, QgsLayoutItemScaleBar and QgsLayoutItemLabel for the marginalia. Every item is positioned with QgsLayoutPoint and sized with QgsLayoutSize, both expressed in QgsUnitTypes.LayoutMillimeters so output is identical across operating systems and printer drivers. Finally, a QgsLayoutExporter renders the finished layout to PDF, image or SVG. QgsPrintLayout is the QGIS 3.x replacement for the QGIS 2.x QgsComposition class, so any tutorial that mentions QgsComposition is out of date.
Building the layout: map frame, legend and scale bar
Automated layout generation follows a deterministic sequence: initialise the project context, create the layout, add and anchor the map frame, attach the marginalia linked to that frame, configure export parameters, then export and clean up. The map frame is the anchor for everything else — the legend and scale bar read their content and units from it — so it must be added and configured first.
The single most important habit is to compute the map extent dynamically from the layer's bounds rather than hardcoding coordinates. Hardcoded extents break the moment the data changes; a buffered layer.extent() frames every dataset consistently. The following function builds a standard A4 layout with a buffered map frame, a linked scale bar and a linked legend.
import os
from qgis.core import (
QgsProject, QgsPrintLayout, QgsLayoutItemMap, QgsLayoutItemScaleBar,
QgsLayoutItemLegend, QgsLayoutExporter, QgsLayoutSize, QgsLayoutPoint,
QgsUnitTypes, QgsRectangle,
)
def generate_automated_layout(output_dir, layer_name, layout_name="AutoMap"):
project = QgsProject.instance()
# 1. Validate layer existence
layers = project.mapLayersByName(layer_name)
if not layers:
raise ValueError(f"Layer '{layer_name}' not found in project.")
layer = layers[0]
layer.setVisible(True)
# 2. Initialize layout
layout = QgsPrintLayout(project)
layout.initializeDefaults()
layout.setName(layout_name)
# 3. Create map item
map_item = QgsLayoutItemMap(layout)
map_item.attemptMove(QgsLayoutPoint(10, 10, QgsUnitTypes.LayoutMillimeters))
map_item.attemptResize(QgsLayoutSize(180, 150, QgsUnitTypes.LayoutMillimeters))
# Set extent with 10% buffer computed from the layer, not hardcoded
extent = layer.extent()
buffer = extent.width() * 0.1
buffered_extent = QgsRectangle(
extent.xMinimum() - buffer, extent.yMinimum() - buffer,
extent.xMaximum() + buffer, extent.yMaximum() + buffer,
)
map_item.setExtent(buffered_extent)
map_item.setKeepLayerSet(True)
map_item.setLayers([layer])
layout.addLayoutItem(map_item)
# 4. Add scale bar, linked to the map frame
scale_bar = QgsLayoutItemScaleBar(layout)
scale_bar.setStyle('Numeric')
scale_bar.setLinkedMap(map_item)
scale_bar.attemptMove(QgsLayoutPoint(10, 165, QgsUnitTypes.LayoutMillimeters))
scale_bar.attemptResize(QgsLayoutSize(50, 10, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(scale_bar)
# 5. Add legend, linked to the map frame
legend = QgsLayoutItemLegend(layout)
legend.setLinkedMap(map_item)
legend.attemptMove(QgsLayoutPoint(140, 10, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(legend)
# 6. Export configuration
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, f"{layout_name}.pdf")
settings = QgsLayoutExporter.PdfExportSettings()
settings.dpi = 300
settings.forceVectorOutput = True
exporter = QgsLayoutExporter(layout)
result = exporter.exportToPdf(output_path, settings)
if result == QgsLayoutExporter.Success:
print(f"Layout exported successfully: {output_path}")
else:
raise RuntimeError(f"PDF export failed with code: {result}")
# Memory cleanup
project.layoutManager().removeLayout(layout)
return output_path
# Execution example:
# generate_automated_layout("/tmp/qgis_exports", "municipal_boundaries")
A few methods carry most of the weight. initializeDefaults() applies a standard page and printer profile so you start from a valid A4 sheet. setKeepLayerSet(True) locks the map frame to the layers you pass with setLayers([layer]), preventing background or reference layers from leaking into the export. setLinkedMap(map_item) is what makes the legend show the right layers and the scale bar read the right units and scale — without it the legend renders empty and the scale bar reports nonsense. And forceVectorOutput = True keeps typography and line work crisp, which is essential for print-grade output.
Adding dynamic titles with expression-driven labels
Static maps rarely stay static. A layout that will be reused across regions, dates or datasets needs a title, a data source note and a generation timestamp that update themselves. QgsLayoutItemLabel supports the same expression syntax as the QGIS label engine: wrap an expression in [% ... %] and set the label mode to render it.
from qgis.core import QgsLayoutItemLabel
title = QgsLayoutItemLabel(layout)
# Expression-driven title: project name + today's date, evaluated at render time
title.setText("[% 'Land Cover — ' || @project_title || ' (' || "
"format_date(now(), 'yyyy-MM-dd') || ')' %]")
title.setFontSize(16)
title.attemptMove(QgsLayoutPoint(10, 2, QgsUnitTypes.LayoutMillimeters))
title.adjustSizeToText()
layout.addLayoutItem(title)
Expressions can read layout variables such as @layout_name, project metadata such as @project_title, and any custom variable you register on the layout with QgsExpressionContextUtils.setLayoutVariable(layout, 'region', region_name). This is the mechanism the atlas map series workflow leans on heavily — @atlas_feature, @atlas_pagename and friends are just more variables in the same context. Registering variables from code keeps the visual design in the template and the data-driven text in your script, so a designer can restyle the label without touching Python.
Exporting to PDF, PNG and SVG
QgsLayoutExporter exposes one method per output family, each with a matching settings object, so the export step is nearly identical regardless of format. PDF is the default for print; PNG suits web and mobile; SVG preserves fully editable vector geometry for downstream design tools.
# PDF — vector output for print
pdf_settings = QgsLayoutExporter.PdfExportSettings()
pdf_settings.dpi = 300
pdf_settings.forceVectorOutput = True
exporter.exportToPdf(os.path.join(output_dir, "map.pdf"), pdf_settings)
# PNG — raster output for the web
img_settings = QgsLayoutExporter.ImageExportSettings()
img_settings.dpi = 150
exporter.exportToImage(os.path.join(output_dir, "map.png"), img_settings)
# SVG — editable vector for design handoff
svg_settings = QgsLayoutExporter.SvgExportSettings()
svg_settings.forceVectorOutput = True
exporter.exportToSvg(os.path.join(output_dir, "map.svg"), svg_settings)
Each exportTo* call returns a result enum; always compare it against QgsLayoutExporter.Success rather than assuming the file was written. Choose DPI deliberately: 300 for print, 96–150 for screen. High DPI on a raster-heavy layout inflates both memory and file size sharply, which matters once you move from one map to hundreds.
Scaling to batch and atlas production
Once the single-layout function is reliable, production usually means one of two patterns. When you need the same template across many layers or many input files, wrap generate_automated_layout() in a loop that iterates over a folder or a CSV manifest, filtering the source and recalculating the extent each time — the reusable-loop patterns in the batch processing guide apply directly here. When you need the same template across many features of one layer — a page per district, per catchment, per parcel — reach for the atlas map series workflow, which iterates a coverage layer and drives per-page extent and labels automatically. For the specific case of iterating the project's existing layouts and writing one file each, see exporting multiple QGIS layouts to PDF with PyQGIS.
Whichever pattern you use, memory discipline is non-negotiable in a loop. Layout rendering holds significant RAM, especially with high-resolution rasters or complex symbology, so remove each layout from the manager immediately after export:
project.layoutManager().removeLayout(layout)
Skipping this step causes memory to grow across a long-running batch and is the usual reason a headless server run dies partway through a map book.
Common errors and resolutions
Blank or clipped exports. The PDF generates but shows empty space or a truncated frame. This happens when the map item extent exceeds the page, or layers are invisible in the project tree. Pass layers explicitly with map_item.setLayers([layer]), call layer.setVisible(True) before export, and confirm the extent fits the page by comparing it against layout.pageCollection().page(0).rect().
CRS mismatch distortions. Shapes appear stretched, rotated or misaligned in the frame when the project CRS differs from the layer CRS without on-the-fly transformation. Set the project CRS explicitly before building the layout — project.setCrs(QgsCoordinateReferenceSystem("EPSG:4326")) — and standardise inputs during ingestion following the coordinate reference systems guidance.
QgsLayoutExporter.FileError. A file-system error despite valid paths usually means insufficient permissions, a directory that does not exist, or a file locked by another process. Wrap the export in try/except, ensure os.makedirs(output_dir, exist_ok=True) runs first, and on Windows confirm no PDF viewer holds the output file open — a file lock will interrupt the writer.
Legend overflows or shows the wrong styles. A legend that displays incorrect symbology or truncates text is almost always missing its map link, or the layer's style has not been refreshed in memory. Always call legend.setLinkedMap(map_item) before adding the item, and trigger layer.triggerRepaint() first if you applied styling dynamically.
Layout coordinates and why items land in odd places
A layout has its own coordinate space, measured in millimetres from the top-left corner of the page, and every item carries a reference point deciding which of its corners that position refers to. Items landing somewhere unexpected almost always come down to one of those two facts.
The other frequent surprise is that an item positioned before the layout has a page ends up nowhere useful. initializeDefaults() creates the single A4 page most scripts assume exists, and calling it after adding items leaves those items outside the page boundary — visible in the designer, absent from the export.
Sizes and positions carry an explicit unit rather than being bare numbers, which is worth appreciating rather than working around. QgsLayoutSize(190, 110, QgsUnitTypes.LayoutMillimeters) cannot be confused with pixels or with page fractions, and a layout built in millimetres exports to any DPI without any of the numbers changing.
Key takeaways
- A
QgsPrintLayoutis a container ofQgsLayoutItemsubclasses; theQgsLayoutItemMapframe is the anchor everything else links to, so add and configure it first. - Compute the map extent from
layer.extent()with a buffer instead of hardcoding coordinates — that single choice makes the same script frame any dataset correctly. - Link the legend and scale bar to the map with
setLinkedMap(map_item), and position every item in millimetres for cross-platform WYSIWYG output. - Use
QgsLayoutItemLabelwith[% ... %]expressions and registered layout variables to keep titles and metadata data-driven. QgsLayoutExporterhandles PDF, PNG and SVG with parallel settings objects; always check the result againstSuccess, and remove each layout from the manager in a loop to avoid memory leaks.- Scale out with the batch processing loop for many files, or the atlas map series workflow for many features of one layer.
Frequently Asked Questions
What is the difference between QgsPrintLayout and the old QgsComposition?QgsPrintLayout is the QGIS 3.x replacement for the legacy QGIS 2.x QgsComposition class. It manages page dimensions, item registration and grid systems through a cleaner item model based on QgsLayoutItem subclasses. All current scripting should target QgsPrintLayout and its companion QgsLayoutExporter.
Why does my exported map come out blank or clipped?
The usual causes are a map item extent that exceeds the page boundaries or layers set to invisible in the project tree. Call layer.setVisible(True), pass the layers explicitly with map_item.setLayers([layer]), and confirm the extent fits the page by comparing it against layout.pageCollection().page(0).rect(). Calculating the extent dynamically from layer bounds avoids hardcoded values that break when data changes.
How do I keep a legend or scale bar synchronized with the map frame?
Both items must be linked to the map with setLinkedMap(map_item) before they are added to the layout. The link tells the legend which layers to display and tells the scale bar which map units and scale to read. Without the link the legend renders empty and the scale bar shows incorrect values.
Can I export to formats other than PDF?
Yes. QgsLayoutExporter exposes exportToImage() for PNG and other raster formats and exportToSvg() for vector SVG, each with a settings object analogous to PdfExportSettings. Set forceVectorOutput = True in the PDF and SVG settings to preserve crisp typography and line work for print-grade output.
How do I put a dynamic title or timestamp on the map?
Add a QgsLayoutItemLabel and set its text to an expression wrapped in [% ... %], for example [% 'Generated ' || format_date(now(), 'yyyy-MM-dd') %]. You can reference project metadata, layout variables like @layout_name, and any custom variable you register with QgsExpressionContextUtils.setLayoutVariable().
How do I prevent memory leaks when generating many layouts in a loop?
After each export, remove the layout from the project with project.layoutManager().removeLayout(layout). Layout rendering holds significant RAM, especially with high-resolution rasters, so failing to release each instance causes memory to grow across a long-running batch. This is essential for stable execution on headless servers.
Why do my layout items end up off the page?initializeDefaults() was called after adding them, or not at all. A layout with no page has no coordinate space for items to sit in, so add the page first and position everything afterwards.
Can I reuse one layout as a template?
Yes. Save it as a .qpt template and load it with layout.loadFromTemplate(), which rebuilds every item from the stored XML. That is usually better than constructing a layout in code, because a cartographer can revise the template without touching the script.
Can I export a layout to SVG?
Yes, with exportToSvg() and its own settings object. SVG output keeps text as text and geometry as paths, which suits handing a map to a designer for finishing in a vector editor.
Why is my legend empty?
Legends are linked to a map item and list the layers that item draws. If the map item's layer list was set explicitly and the legend was added before it, the legend has nothing to enumerate — call legend.updateLegend() after the map item is configured.
How do I add a page to an existing layout?
Construct a QgsLayoutItemPage, set its size and orientation, and add it through the layout's page collection. Items are positioned relative to the whole layout rather than to a page, so a second page's items sit below the first in layout coordinates.
Can a label show the current date automatically?
Yes — set the label text to an expression referencing now() and enable expression evaluation on the item. The value is resolved at render time, so an export made tomorrow carries tomorrow's date.