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.

Design in the designer, data in codeA cartographer designs a layout in the Layout Designer and saves it as a qpt template: page size, frame, title block with placeholders, a map item with id main_map, a legend, a scale bar and logos. A script loads the template into a new layout, fills the placeholders, sets the map extent and layers, and exports. The template can be redesigned without touching the code, as long as item ids stay the same.The template is the contract between design and codedesignerpage, fonts, frametitle blockitems with idssaves .qpttemplate.qpt[%title%]main_mapscriptload templatefill placeholdersset extent, layersexport

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.

Placeholders through variablesA label in the template contains the expression bracket with the layout variable district_name and another with the variable map_ref. The script sets those variables on the layout before export. When the label renders, the expressions are evaluated and the label shows the district name and reference. The template never needs editing for a new district.Set variables, let labels evaluatetemplate label[% @district_name %]Ref: [% @map_ref %]script setslayout variablesrenderedNordRef: DM-2026-014

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.

One template, many mapsFor each district the script loads a fresh layout from the template, sets the variables and the map extent, checks the template, and exports a PDF named after the district. Loading a fresh layout per map avoids state leaking from one export to the next.Fresh layout per map, same templatedistrictsNord, Süd, Ost …per districtload template · set varsextent · check · exportPDFsone per district

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

  • itemById returns 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(...).