Atlas Expressions and Dynamic Text in PyQGIS
An atlas without expressions produces four hundred identical maps at four hundred different places. The expressions are what make each page name itself, title itself, filter its own labels and land in a sensibly named file — and all of them are properties you can set from Python, which is what turns an atlas into something a scheduled job produces rather than something a person clicks through.
This recipe belongs to Automating Atlas Map Series. It covers the atlas variables, wiring filename and page name expressions, dynamic labels, filtering and sorting the coverage, and testing a handful of pages before committing to the whole run.
Prerequisites
- QGIS 3.34 LTR or newer.
- A layout with a map item, and a coverage layer — see configuring an atlas coverage layer.
Wire the atlas from Python
from qgis.core import QgsProject
project = QgsProject.instance()
layout = project.layoutManager().layoutByName("site_atlas")
sites = project.mapLayersByName("sites")[0]
atlas = layout.atlas()
atlas.setEnabled(True)
atlas.setCoverageLayer(sites)
atlas.setPageNameExpression('"district" || \' — \' || "site_id"')
atlas.setFilenameExpression("'site_' || \"site_id\" || '_' || format_date(now(), 'yyyyMMdd')")
atlas.setSortFeatures(True)
atlas.setSortExpression('"district"')
atlas.setSortAscending(True)
Breakdown: Inside these expressions, bare field names refer to the coverage layer, because the atlas evaluates them against the current coverage feature — which is why "site_id" works here without attribute(@atlas_feature, ...). The filename expression must not include a directory or an extension; the exporter supplies both, and putting a slash in it produces a confusing failure. Sorting matters more than it looks on a printed atlas: an unsorted coverage produces pages in provider order, which is arbitrary and changes between exports, making a page number useless as a reference.
Filtering the coverage
atlas.setFilterFeatures(True)
atlas.setFilterExpression('"status" = \'open\' AND "area_m2" > 500')
print("pages:", atlas.count())
Breakdown: As with layout tables, the boolean switch and the expression are separate, and setting the expression alone does nothing. count() re-evaluates the filter and returns how many pages the export will produce, which is the single most useful thing to print before starting a long run — a filter that accidentally matches everything turns a twelve-page atlas into a four-hundred-page one, and finding that out from count() costs a second.
Dynamic text on the page
from qgis.core import QgsLayoutItemLabel
title = layout.itemById("title")
title.setText('[% "district" || \' — \' || "site_id" %]')
footer = layout.itemById("footer")
footer.setText(
"[% 'Sheet ' || @atlas_featurenumber || ' of ' || @atlas_totalfeatures %]"
)
Breakdown: Everything between [% and %] is evaluated as an expression and the rest is literal, so a label can mix fixed and dynamic text freely. Inside a label on an atlas page, bare field names again resolve against the coverage feature. Where a label needs a field from a different layer — a project-wide contact, say — an aggregate or an explicit attribute() call is required, because there is no implicit access. itemById finds items by the id set in their properties, which is worth assigning to every item a script will touch.
Controlling the map per page
The map item, not the atlas, decides how each page frames its feature, and the three modes give very different atlases.
map_item = layout.itemById("main_map")
map_item.setAtlasDriven(True)
map_item.setAtlasScalingMode(QgsLayoutItemMap.Auto)
map_item.setAtlasMargin(0.15)
Breakdown: Auto fits each feature with a margin expressed as a fraction of the feature's size, so a small site and a large one each fill the frame — good for inspection sheets, bad for anything where pages are compared, because every page is at a different scale. Fixed keeps the map item's own scale and simply centres on each feature, which makes pages comparable and lets a large feature overflow. Predefined picks the smallest scale from the project's predefined list that fits the feature, which is the compromise most printed series want: comparable, round-numbered scales, and nothing clipped.
Because the scale can vary, any label quoting it should read it rather than state it:
scale_label = layout.itemById("scale_note")
scale_label.setText("[% 'Scale 1:' || format_number(@map_scale, 0) %]")
Breakdown: @map_scale is evaluated in the context of the map the label is associated with, and a label with no associated map gets the layout's first one — which is right on a single-map page and wrong on a page with an inset. Setting the label's setLinkedMap() removes the ambiguity. A hard-coded scale on an Auto atlas is one of the more embarrassing errors to reach print, because it is correct on the page the author checked.
Spatial filters that follow the page
@atlas_geometry opens up filters that are about place rather than attributes, which is how a page shows only the features belonging to its own area.
neighbours = project.mapLayersByName("neighbouring_sites")[0]
neighbours.setSubsetString("")
rule_filter = "intersects($geometry, buffer(@atlas_geometry, 500))"
Breakdown: Used as a rule filter in a rule-based renderer, or as a label filter, this shows only features within 500 m of the current coverage feature — so the surrounding context appears without the whole layer being drawn. It is evaluated per page and per feature, so it is not free: on a large layer, restricting the layer's own extent through the map item is cheaper than an expression that tests every feature. Where the coverage features are polygons, intersects against the geometry directly is usually enough and needs no buffer.
Testing before a long export
atlas.beginRender()
for index in range(min(3, atlas.count())):
atlas.seekTo(index)
print(index, "→", atlas.currentFilename(), "|", atlas.nameForPage(index))
print(" title renders as:", title.currentText())
atlas.endRender()
Breakdown: beginRender and seekTo step the atlas without exporting anything, which is the cheapest possible way to check that expressions produce what you expect. currentFilename() shows the resolved filename for the current feature — the place where a duplicate name or an illegal character reveals itself, and duplicates matter because the exporter overwrites rather than warning. currentText() on a label returns the evaluated text rather than the expression source. Always pair beginRender with endRender; leaving the atlas in render mode confuses the GUI and any subsequent export.
Watch for duplicates explicitly on a large coverage:
from collections import Counter
atlas.beginRender()
names = []
for index in range(atlas.count()):
atlas.seekTo(index)
names.append(atlas.currentFilename())
atlas.endRender()
clashes = [name for name, n in Counter(names).items() if n > 1]
print(len(clashes), "duplicate filenames")
Breakdown: A filename expression built from a field that is not unique produces pages that silently overwrite each other, and the result is an output folder with fewer files than the atlas had pages — a discrepancy nobody notices unless they count. Appending @atlas_featurenumber to the expression guarantees uniqueness at the cost of a less tidy name, which is usually the right trade.
Page names, and why they are worth setting
The page name expression looks cosmetic and is not. It supplies the bookmark label in a combined PDF, the entry in the layout's page-name list, and the value of @atlas_pagename for any label that wants to print it. A four-hundred-page PDF whose bookmarks all read "Page 12" is considerably less useful than one whose bookmarks name the district and site.
atlas.setPageNameExpression(
'"district" || \' \' || lpad(@atlas_featurenumber, 3, \'0\')'
)
Breakdown: Combining a meaningful field with a zero-padded sequence number gives names that sort correctly as text and remain unique even where the field does not — lpad is what makes 9 sort before 10 rather than after it. Keeping the page name and the filename expression consistent, so a bookmark and a file refer to the same thing by the same words, saves everyone downstream a translation step.
One caution: the page name is evaluated once per feature and is not re-evaluated if the coverage changes underneath a rendering atlas, so a script that edits the coverage layer mid-export produces names that no longer match their pages. Finish the export, then edit.
QGIS version compatibility
QgsLayoutAtlas and the layout.atlas() accessor arrived with the layout rewrite in QGIS 3.0 and are unchanged in shape since. The atlas variables @atlas_feature, @atlas_featurenumber, @atlas_totalfeatures, @atlas_pagename and @atlas_geometry have been present throughout 3.x. nameForPage was added in 3.4.
Troubleshooting
- Labels print their own expression. Missing
[%and%]delimiters. - The filter has no effect.
setFilterFeatures(True)was not called. - Fewer files than pages. Duplicate filenames overwriting each other.
- Pages come out in a different order each export. Sorting is off, so provider order applies.
- A field name is not found in a label. The item's layer context is not the coverage layer; use
attribute(@atlas_feature, 'field'). - The export fails on the first page. The filename expression contains a path separator or an extension.
Conclusion
Set the coverage, the sort, the filter and the two expressions from Python, then step three pages with beginRender/seekTo and read the resolved filenames and labels before exporting anything. Checking count() and the duplicate filenames takes seconds and catches the two mistakes that otherwise only appear after a four-hundred-page run.
Frequently Asked Questions
Can the map's scale differ per page? Yes — set the map item's atlas driving mode to a fixed scale, a margin around the feature, or a predefined scale list. The predefined-scale mode is what gives a consistent set of round scales across a series.
How do I show only the current feature's labels?
Filter the labelled layer with an expression referencing @atlas_feature, or use @atlas_geometry in a spatial condition. Both are evaluated per page.
Can I export each page to a separate PDF? Yes — the exporter has a per-feature mode, and the filename expression names each file. A single combined PDF is the alternative, covered in exporting atlas pages to individual PDFs.
Does an atlas work with a table item? Yes, and it is the point of the relation-children table source — see adding an attribute table to a layout.