Generating an Atlas Map Series Programmatically
Drive the QGIS atlas engine in Python with QgsLayoutAtlas: bind a coverage layer, set a page-name expression, and export one map sheet per feature as PDF or…
TL;DR: Get the layout’s atlas() (a QgsLayoutAtlas), call setCoverageLayer() and setEnabled(True), set a filename/page-name expression, then export with QgsLayoutExporter.exportToPdfs(atlas, ...) for one file per feature — or drive beginRender() / next() yourself for full control over each sheet.
This page is part of the automating print layouts and map export guide, which sits inside the broader Plugin Development & UI Integration in QGIS reference. Where exporting a single print layout to PDF with QgsLayoutExporter produces one document, the atlas engine turns a single layout into a series — one map sheet per feature in a coverage layer. Think map books, per-parcel cadastral prints, or a survey report with one page per site.
Complete Runnable Script
The script below loads a project, resolves a print layout by name, wires up its QgsLayoutAtlas, and exports one PDF per coverage feature. It also demonstrates the manual beginRender() / next() iteration path, which you need whenever you want to inspect, log, or post-process each individual sheet. Both approaches share the same atlas configuration; only the export mechanism differs.
"""
atlas_export.py
Configure and export a QGIS atlas map series from an existing print layout.
Compatible with QGIS 3.28+ LTR. Runs headless or inside the QGIS Python console.
"""
from __future__ import annotations
from pathlib import Path
from qgis.core import (
QgsApplication,
QgsLayout,
QgsLayoutAtlas,
QgsLayoutExporter,
QgsLayoutItemMap,
QgsPrintLayout,
QgsProject,
QgsVectorLayer,
)
def configure_atlas(
layout: QgsPrintLayout,
coverage_layer: QgsVectorLayer,
*,
page_name_expr: str = '"name"',
filter_expr: str | None = None,
map_margin: float = 0.10,
) -> QgsLayoutAtlas:
"""
Bind a coverage layer to a layout's atlas and mark its map item as
atlas-driven.
Args:
layout: The print layout carrying at least one map item.
coverage_layer: Vector layer whose features drive the iteration.
page_name_expr: QGIS expression yielding a per-feature page name;
doubles as the default filename token.
filter_expr: Optional expression to restrict which features render.
map_margin: Fractional padding around each feature (0.10 = 10%).
Returns:
The configured, enabled QgsLayoutAtlas.
Raises:
ValueError: if the layout has no QgsLayoutItemMap to drive.
"""
atlas: QgsLayoutAtlas = layout.atlas()
atlas.setCoverageLayer(coverage_layer)
atlas.setEnabled(True)
atlas.setHideCoverage(False)
atlas.setPageNameExpression(page_name_expr)
atlas.setFilenameExpression("'sheet_' || @atlas_featurenumber")
if filter_expr:
atlas.setFilterFeatures(True)
atlas.setFilterExpression(filter_expr)
# Find the first map item and let the atlas frame each feature.
map_item: QgsLayoutItemMap | None = next(
(item for item in layout.items() if isinstance(item, QgsLayoutItemMap)),
None,
)
if map_item is None:
raise ValueError("Layout contains no QgsLayoutItemMap to drive.")
map_item.setAtlasDriven(True)
map_item.setAtlasScalingMode(QgsLayoutItemMap.Auto)
map_item.setAtlasMargin(map_margin)
return atlas
def export_atlas_to_pdfs(
layout: QgsPrintLayout,
atlas: QgsLayoutAtlas,
out_dir: Path,
) -> None:
"""
Export one PDF per feature using the QgsLayoutExporter atlas overload.
Raises:
RuntimeError: if the exporter reports a non-Success result.
"""
out_dir.mkdir(parents=True, exist_ok=True)
exporter = QgsLayoutExporter(layout)
settings = QgsLayoutExporter.PdfExportSettings()
settings.dpi = 300
settings.rasterizeWholeImage = False
result, error = exporter.exportToPdfs(atlas, str(out_dir), settings)
if result != QgsLayoutExporter.Success:
raise RuntimeError(f"Atlas PDF export failed: {error} (code {result})")
def export_atlas_manually(
layout: QgsPrintLayout,
atlas: QgsLayoutAtlas,
out_dir: Path,
) -> list[Path]:
"""
Iterate the atlas by hand with beginRender()/next().
Use this path when you need per-sheet logging, conditional export, or
custom filenames beyond what the filename expression supports.
Returns:
The list of files written, in iteration order.
"""
out_dir.mkdir(parents=True, exist_ok=True)
exporter = QgsLayoutExporter(layout)
written: list[Path] = []
if not atlas.beginRender():
raise RuntimeError("atlas.beginRender() returned False — no features?")
# first() positions the atlas on feature 0 and returns True while valid.
keep_going = atlas.first()
while keep_going:
# currentFilename() is resolved from setFilenameExpression().
target = out_dir / f"{atlas.currentFilename()}.png"
result = exporter.exportToImage(
str(target), QgsLayoutExporter.ImageExportSettings()
)
if result != QgsLayoutExporter.Success:
raise RuntimeError(f"Failed on {target.name}: code {result}")
written.append(target)
keep_going = atlas.next()
atlas.endRender()
return written
def main() -> None:
"""Resolve a layout, configure its atlas, and export the series."""
project = QgsProject.instance()
project.read("/data/projects/parcels.qgz")
layout_obj: QgsLayout | None = (
project.layoutManager().layoutByName("Parcel Map Book")
)
if not isinstance(layout_obj, QgsPrintLayout):
raise RuntimeError("Print layout 'Parcel Map Book' not found.")
coverage = project.mapLayersByName("parcels")[0]
atlas = configure_atlas(layout_obj, coverage, page_name_expr='"parcel_id"')
export_atlas_to_pdfs(layout_obj, atlas, Path("/data/output/atlas_pdfs"))
if __name__ == "__main__":
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()
try:
main()
finally:
qgs.exitQgis()
Run it headless with python atlas_export.py, or paste configure_atlas and export_atlas_to_pdfs into the QGIS Python console and call them against an already-open project — the atlas configuration is identical either way.
How the Atlas Iteration Works
At its core the atlas engine is a controller that walks a coverage layer’s features, and for each one repositions every atlas-driven map item, resolves the page-name and filename expressions, and renders the layout as a self-contained sheet. The diagram below shows the fan-out from a single coverage layer to N output files.
Architecture Breakdown
QgsLayoutAtlas — the contract
Every QgsPrintLayout owns exactly one QgsLayoutAtlas, retrieved with layout.atlas(). You never construct it directly; you configure the instance the layout already carries. Three properties do the heavy lifting:
setCoverageLayer(layer)binds the vector layer whose features drive iteration.coverageLayer()reads it back. The feature count of this layer equals the number of sheets the atlas will emit (minus any filtered out).setPageNameExpression(expr)accepts a QGIS expression string evaluated per feature. It names each page in the layout designer’s page list and, importantly, is available as a token for output naming. A bare field reference like"parcel_id"is a valid expression.setFilterExpression(expr)combined withsetFilterFeatures(True)restricts iteration to features matching a boolean expression — for example"status" = 'approved'. WithoutsetFilterFeatures(True), the filter string is ignored entirely.
Call setEnabled(True) to make the atlas active; a disabled atlas exports as a single ordinary layout. Inside expressions you also gain atlas-specific variables: @atlas_featurenumber (1-based), @atlas_totalfeatures, @atlas_pagename, and @atlas_feature (the current QgsFeature). These are the building blocks for deterministic filenames — 'sheet_' || @atlas_featurenumber guarantees a stable ordering that does not depend on attribute uniqueness.
The atlas-driven map item
An atlas that iterates features but never moves the map produces N identical sheets. The link between the two is QgsLayoutItemMap.setAtlasDriven(True): it tells that map item to recentre on the current feature’s geometry each iteration. A layout can contain several maps; only those flagged atlas-driven follow the coverage feature, so an overview inset can stay fixed while the main map pans.
How the map frames each feature is governed by the scaling mode set with setAtlasScalingMode():
QgsLayoutItemMap.Auto— pick the best scale so the feature plus its margin fills the frame. Combine withsetAtlasMargin(0.10)for 10% padding.QgsLayoutItemMap.Fixed— hold the map’s own scale constant and simply centre on the feature. Ideal for consistent-scale map books.QgsLayoutItemMap.Predefined— snap to the nearest scale in the project’s predefined scale list.
Set the scaling mode before exporting; changing it mid-render has no effect on sheets already produced.
Export overloads: single-file vs multi
QgsLayoutExporter exposes atlas-aware overloads that accept the QgsLayoutAtlas as their first argument and manage beginRender()/next() internally:
exportToPdfs(atlas, baseFilePath, settings)writes one PDF per feature, naming each from the filename expression. This is the map-book-as-loose-pages case.exportToPdf(atlas, filePath, settings)(singular) concatenates every sheet into one multi-page PDF — the bound map book. Note the singularexportToPdfversus pluralexportToPdfs; the distinction is the entire difference between a folder of files and one document.exportToImage(atlas, dir, extension, settings)writes one raster (PNG, JPEG, TIFF) per feature into a directory.
All of these return a QgsLayoutExporter.ExportResult enum. Compare against QgsLayoutExporter.Success and surface anything else — common non-success codes are FileError (unwritable path), PrintError, and Canceled. When you need behaviour the overloads do not offer — per-sheet logging, conditional skipping, uploading each file as it lands — fall back to the manual first()/next()/endRender() loop shown in the runnable script, calling a plain single-layout exporter inside the loop.
Running the Export Off the UI Thread
Atlas exports are CPU- and I/O-heavy: a coverage layer with a few hundred features can take minutes at 300 DPI. Inside a plugin, running that synchronously freezes the QGIS window and risks the OS marking it unresponsive. Wrap the export in a QgsTask so it runs on a background thread and reports progress back to the UI. The asynchronous task execution with QgsTask guide covers the full pattern; the skeleton below shows the atlas-specific wiring.
"""atlas_task.py — run an atlas export inside a background QgsTask."""
from __future__ import annotations
from pathlib import Path
from qgis.core import QgsLayoutAtlas, QgsPrintLayout, QgsTask
class AtlasExportTask(QgsTask):
"""Export an atlas series without blocking the QGIS UI thread."""
def __init__(
self, layout: QgsPrintLayout, atlas: QgsLayoutAtlas, out_dir: Path
) -> None:
super().__init__("Atlas export", QgsTask.CanCancel)
self._layout = layout
self._atlas = atlas
self._out_dir = out_dir
self._error: str | None = None
def run(self) -> bool:
"""Executed on the worker thread. Never touch the GUI here."""
try:
from atlas_export import export_atlas_to_pdfs
export_atlas_to_pdfs(self._layout, self._atlas, self._out_dir)
return True
except Exception as exc: # noqa: BLE001 — report to finished()
self._error = str(exc)
return False
def finished(self, ok: bool) -> None:
"""Executed back on the main thread — safe to update UI here."""
if not ok:
print(f"Atlas export failed: {self._error}")
Add the task with QgsApplication.taskManager().addTask(task) and it runs to completion in the background. Because rendering happens off-thread, keep every QGIS-object mutation inside run() and confine UI updates to finished().
Production Best Practices
- Set
setEnabled(True)explicitly. A layout whose atlas is configured but not enabled exports as a single sheet with no warning. - Flag at least one map item as atlas-driven. Otherwise the map never pans and you get N copies of the same view.
- Prefer
@atlas_featurenumberfor filenames. Attribute-derived names can collide or contain path-hostile characters; the feature number is unique and monotonic. Sanitise attribute-based names if you must use them. - Always check the
ExportResult. Treat anything other thanQgsLayoutExporter.Successas a hard failure and log the code — silent partial exports are worse than a loud stop. - Choose the scaling mode deliberately.
Fixedfor uniform map books,Autowith a margin for varied feature sizes. Mixing them across a series confuses readers. - Use
setFilterFeatures(True)alongsidesetFilterExpression(). The filter string is inert without the boolean flag. - Export long series inside a
QgsTaskso the QGIS UI stays responsive and the job is cancellable. - Balance DPI against volume. 300 DPI is print-quality; for a proof run or hundreds of sheets, drop to 150 DPI to cut render time and file size.
Related
- Automating Print Layouts and Map Export — parent guide to the layout and export subsystem, from map items to exporters
- Exporting a Print Layout to PDF with
QgsLayoutExporter— the single-document export path that the atlas overloads build on - Asynchronous Task Execution with
QgsTask— run heavy atlas exports on a background thread without freezing the QGIS UI