Load a Print Layout from a QPT Template in PyQGIS
Building a print layout item by item in code is precise but tedious, and the result rarely looks as good as one designed by hand in the Layout Designer. Templates split the work sensibly: a cartographer designs the page — frame, title block, logos, legend placement, fonts — and saves it as a .qpt file; a script loads that template and fills in what changes from map to map: the extent, the layers, the title, the date. The design stays in the designer's hands, the automation stays in code.
This recipe belongs to Automated Map Layout Generation. It loads a template into a new layout, replaces text placeholders, finds items by id to set the map and legend, checks that a template matches what the code expects, and exports a series of maps from one template.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A template saved from the Layout Designer (Layout → Save as Template). Give the items your code will touch an explicit Item ID in the Item Properties panel —
main_map,title,legend— rather than relying on QGIS's automatic ids. - A project with the layers the template's map should show.
Load the template into a new layout
A template is the layout's XML. Loading it means reading the file into a DOM document and asking a fresh layout to build its items from it.
from qgis.core import (QgsProject, QgsPrintLayout, QgsReadWriteContext,
QgsLayoutItemMap)
from qgis.PyQt.QtXml import QDomDocument
def layout_from_template(path, name, project=None):
project = project or QgsProject.instance()
with open(path, encoding="utf-8") as fh:
doc = QDomDocument()
if not doc.setContent(fh.read()):
raise ValueError(f"{path} is not valid XML")
layout = QgsPrintLayout(project)
layout.initializeDefaults()
items, ok = layout.loadFromTemplate(doc, QgsReadWriteContext(), True)
if not ok:
raise RuntimeError(f"could not load template {path}")
layout.setName(name)
return layout
layout = layout_from_template("/data/templates/a3_landscape.qpt", "District map — Nord")
print([item.id() for item in layout.items() if hasattr(item, "id") and item.id()])
Breakdown: initializeDefaults gives the layout a default page so that templates without explicit pages still load; the template's own page settings then replace it. The third argument to loadFromTemplate clears existing items before loading. The function returns the new layout without adding it to the project's layout manager, which keeps batch runs from filling the project with hundreds of layouts; add it with project.layoutManager().addLayout(layout) if you want to inspect it in the designer. Printing item ids is the quickest way to see what the template offers.
Replace text placeholders
Static labels in the template can contain expressions in [% %] brackets, evaluated when the layout renders. For values only the script knows — a district name, a reference number — set project or layout variables and reference them from the template.
from qgis.core import QgsExpressionContextUtils
QgsExpressionContextUtils.setLayoutVariable(layout, "district_name", "Nord")
QgsExpressionContextUtils.setLayoutVariable(layout, "map_ref", "DM-2026-014")
QgsExpressionContextUtils.setLayoutVariable(layout, "prepared_by", "GIS team")
title = layout.itemById("title")
print(title.text()) # the raw template text with [% %] expressions
title.refreshExpressionContext()
print(title.currentText()) # the evaluated text
Breakdown: Layout variables are scoped to one layout, so a batch can set different values on each layout created from the same template. In the template, labels refer to them as [% @district_name %]; other expressions work too, such as [% format_date(now(), 'd MMMM yyyy') %] for the export date. text() returns the raw template text and currentText() the evaluated result, which makes checking easy. Label items must have "Render as HTML" off or on consistently with how the template was designed, but placeholders work in both. More expression-driven text is covered in atlas expressions and dynamic text.
Set the map extent and layers
The map item is found by its id. Its extent, layers and scale are set from code; everything else — frame, grid, overview — comes from the template.
from qgis.core import QgsRectangle
district = next(QgsProject.instance().mapLayersByName("districts")[0]
.getFeatures("\"name\" = 'Nord'"))
map_item = layout.itemById("main_map")
if not isinstance(map_item, QgsLayoutItemMap):
raise RuntimeError("template has no map item with id 'main_map'")
extent = QgsRectangle(district.geometry().boundingBox())
extent.scale(1.08) # 8 % margin
map_item.zoomToExtent(extent)
layers = [QgsProject.instance().mapLayersByName(n)[0] for n in ("roads", "buildings", "districts")]
map_item.setLayers(layers)
map_item.setKeepLayerSet(True)
map_item.refresh()
print("scale 1:", round(map_item.scale()))
Breakdown: zoomToExtent fits the extent into the map item's frame, adjusting for its aspect ratio, so the district always fits regardless of shape. Setting layers explicitly and locking them with setKeepLayerSet makes the map independent of what happens to be visible in the project's layer tree — important for batch exports. If the design calls for fixed scales, round with map_item.setScale(…) after zooming. Adding a map item and setting its extent covers extents, scales and map themes in more depth.
Keep the legend and scale bar in step
Templates often contain a legend and a scale bar linked to the map item. After the script changes the map's layers and extent, those linked items need to follow — the legend should list exactly the map's layers, the scale bar should reflect the new scale.
from qgis.core import QgsLayoutItemLegend, QgsLayoutItemScaleBar
legend = layout.itemById("legend")
if isinstance(legend, QgsLayoutItemLegend):
legend.setLinkedMap(map_item)
legend.setAutoUpdateModel(False)
root = legend.model().rootGroup()
root.removeAllChildren()
for lyr in layers:
root.addLayer(lyr)
legend.updateLegend()
legend.adjustBoxSize()
scalebar = layout.itemById("scalebar")
if isinstance(scalebar, QgsLayoutItemScaleBar):
scalebar.setLinkedMap(map_item)
scalebar.applyDefaultSize()
scalebar.update()
Breakdown: Rebuilding the legend's tree from the same list of layers given to the map keeps the two consistent and drops any layers the template's designer happened to have in the project. adjustBoxSize resizes the legend frame to its new content; if the template reserves a fixed area, check that long layer names still fit. applyDefaultSize lets the scale bar choose segment sizes that suit the new scale, so a template designed at 1:10,000 still produces a sensible bar at 1:50,000. Adding a legend to a layout covers legend styling in more detail.
Check the template against the code
Templates get edited. When a designer renames an item or deletes the legend, the script should fail clearly, not export a map with a missing title.
REQUIRED = {"main_map": QgsLayoutItemMap, "title": None, "legend": None, "scalebar": None}
def check_template(layout, required=REQUIRED):
problems = []
for item_id, cls in required.items():
item = layout.itemById(item_id)
if item is None:
problems.append(f"missing item '{item_id}'")
elif cls is not None and not isinstance(item, cls):
problems.append(f"item '{item_id}' is {type(item).__name__}, expected {cls.__name__}")
if problems:
raise ValueError("template does not match the script: " + "; ".join(problems))
check_template(layout)
Breakdown: Listing the item ids the code relies on, with their expected types, turns the template into a checked contract. Running the check right after loading means a broken template stops the batch before anything is exported, with a message that tells the designer exactly what to restore. Keep the list next to the template — in the same folder or repository — so the two evolve together.
Export a series from one template
The payoff: one template, one loop, a map per district, each with its own extent, title and reference.
from qgis.core import QgsLayoutExporter
districts = QgsProject.instance().mapLayersByName("districts")[0]
for i, f in enumerate(districts.getFeatures(), start=1):
lay = layout_from_template("/data/templates/a3_landscape.qpt", f"District {f['name']}")
check_template(lay)
QgsExpressionContextUtils.setLayoutVariable(lay, "district_name", f["name"])
QgsExpressionContextUtils.setLayoutVariable(lay, "map_ref", f"DM-2026-{i:03d}")
m = lay.itemById("main_map")
ext = QgsRectangle(f.geometry().boundingBox())
ext.scale(1.08)
m.zoomToExtent(ext)
result = QgsLayoutExporter(lay).exportToPdf(f"/data/maps/district_{f['name']}.pdf",
QgsLayoutExporter.PdfExportSettings())
print(f["name"], "ok" if result == QgsLayoutExporter.Success else result)
Breakdown: Loading a fresh layout for each map guarantees that nothing — a changed scale, a moved item — carries over between exports. For a long series where every page shares the same design and only the extent and text change, an atlas built into the template is an alternative that QGIS drives itself, as in generating an atlas PDF. The template approach shines when pages need logic the atlas cannot express: different layer sets per page, conditional items, or data from outside the coverage layer.
QGIS version compatibility
QgsPrintLayout.loadFromTemplate, itemById, layout variables and QgsLayoutExporter work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. Templates saved in older QGIS 3 versions load in newer ones; templates saved in QGIS 4 may contain items or properties older versions ignore. On QGIS 4, QgsLayoutExporter.Success is QgsLayoutExporter.ExportResult.Success.
Troubleshooting
itemByIdreturns None. The item has an automatic id; set an explicit Item ID in the designer and save the template again.- Placeholders print literally. The label refers to a variable that was not set, or the expression has a typo; check with
currentText(). - Fonts differ from the designer. The fonts are not installed on the machine running the export.
- Maps show the wrong layers. The layer set was not locked; set layers and
setKeepLayerSet(True).
Conclusion
Let designers own the page and save it as a template with explicit item ids, load it into a fresh layout per map, fill text through layout variables referenced as [% @var %], set extent and layers on the map item by id, check the template against the ids your code needs, and export series in a loop.
Frequently Asked Questions
Can templates include an atlas configuration? Yes. The atlas settings are part of the template; set the coverage layer after loading if it differs.
Where should templates live? In a shared folder or repository next to the scripts that use them, versioned together.
Can I load a template into an existing layout?
Yes — pass False as the clear argument to add the template's items to the existing ones.
How do I make the template's map use a map theme?
Set the theme in the designer, or call map_item.setFollowVisibilityPreset(True) and setFollowVisibilityPresetName(...).