Skip to content
← Back to Map layouts in QGIS: from template to automated map series
Map layouts · Part 17 of 18Intermediate· 10 min readSources

A Complete PyQGIS Map Pipeline: Settings, Functions and a Log File

Put the previous lessons together as a pipeline: all the settings in one dictionary, a log file that records every step and failure, small functions for each job, checks for duplicate file names, and a summary at the end.

You now have the pieces: a template, a loop, checks and an export. A pipeline joins them into one repeatable sequence that you can run next month, give to a colleague, or leave to run on its own. The aim is not cleverness. The aim is a script that is easy to understand, easy to rerun, and easy to fix when something goes wrong. This lesson turns the loop of the last two into that.

Open scripts/lesson7_pipeline.py. It is the longest script so far, so go step by step.

The shape of the folder

The download is laid out the way a pipeline should be.

part-c/
  bengaluru_layout_start.qgz     the project
  data/                          the input data
  templates/                     the template (.qpt)
  scripts/                       the scripts
  output/                        everything the script writes
    pipeline/                    the PDFs
    pipeline/logs/               the log file

Keep the inputs, the template, the scripts and the outputs apart. A script that writes only into output cannot damage your data, and you can delete output and make it again.

Before the steps: imports

# Lesson 7: put it together as a pipeline: settings in one place, a log file, small functions, and a summary.
import logging
import re
from pathlib import Path
from qgis.core import (
    Qgis,
    QgsProject,
    QgsPrintLayout,
    QgsReadWriteContext,
    QgsLayoutItemMap,
    QgsLayoutItemLabel,
    QgsLayoutExporter,
    QgsExpressionContextUtils,
)
from qgis.PyQt.QtXml import QDomDocument

logging is Python's way of recording messages. Qgis gives the version of QGIS, which goes into the log.

Step 1: All the settings in one dictionary

# Step 1: all the settings that might change, in one dictionary. The rest of the script only reads them.
config = {
    "regions_layer": "Parks and lakes, share of area (%)",
    "name_field": "short",
    "title_field": "title",
    "template": "templates/parks_and_lakes_template.qpt",
    "padding": 1.15,
    "output_folder": "output/pipeline",
}
REQUIRED_ITEMS = {"main_map": QgsLayoutItemMap, "map_title": QgsLayoutItemLabel}

project = QgsProject.instance()
folder = Path(project.homePath())
output_folder = folder / config["output_folder"]
(output_folder / "logs").mkdir(parents=True, exist_ok=True)

A dictionary stores settings as key: value pairs. Everything that might change is here: the layer, the fields, the template, the padding and the output folder. The rest of the script reads from config, and nothing else has to be edited when you move to new data. REQUIRED_ITEMS is the safety net from Lesson 4.

Step 2: A log file

# Step 2: a log file. A dedicated logger is used, and old handlers are removed, so that running the
# script twice does not write every line twice.
logger = logging.getLogger("map_series")
logger.setLevel(logging.INFO)
for handler in list(logger.handlers):
    handler.close()
    logger.removeHandler(handler)
file_handler = logging.FileHandler(output_folder / "logs" / "map_generation.log", mode="w", encoding="utf-8")
file_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(file_handler)

print shows a message and forgets it. A log writes each message to a file with the time, so you can read what happened last night, or last month. The format %(asctime)s %(levelname)s %(message)s gives lines such as 2026-10-13 21:56:43 INFO Exported East.pdf.

Two details are easy to get wrong. A dedicated logger called map_series is used, because the root logger may already be set up by QGIS. And any old handlers are closed and removed first. Without that, running the script twice in one QGIS session adds a second handler, and every line is written twice.

Step 3: Small functions

# Step 3: small functions, each doing one job
def validate_inputs():
    """Return the regions layer and the template document, or raise an error that says what is wrong."""
    matches = project.mapLayersByName(config["regions_layer"])
    if not matches:
        raise ValueError(f"Layer not found: {config['regions_layer']}")
    layer = matches[0]
    if not layer.isValid():
        raise ValueError(f"Layer is not valid: {config['regions_layer']}")
    for field in (config["name_field"], config["title_field"]):
        if layer.fields().indexFromName(field) == -1:
            raise ValueError(f"Field not found: {field}")
    template_path = folder / config["template"]
    if not template_path.exists():
        raise FileNotFoundError(f"Template not found: {template_path}")
    document = QDomDocument()
    ok, message, line, column = document.setContent(template_path.read_text(encoding="utf-8"))
    if not ok:
        raise ValueError(f"Template is not valid XML (line {line}): {message}")
    return layer, document


def make_layout(document):
    layout = QgsPrintLayout(project)
    layout.loadFromTemplate(document, QgsReadWriteContext(), True)
    for item_id, item_class in REQUIRED_ITEMS.items():
        if not isinstance(layout.itemById(item_id), item_class):
            raise ValueError(f"The template has no usable item with the ID {item_id!r}")
    return layout


def safe_filename(value):
    return re.sub(r'[<>:"/\\|?*]', "_", str(value).strip()) or "unnamed"


def export_layout(layout, path):
    result = QgsLayoutExporter(layout).exportToPdf(str(path), QgsLayoutExporter.PdfExportSettings())
    if result != QgsLayoutExporter.Success:
        raise RuntimeError(f"Export failed ({result}): {path.name}")
    if not path.exists() or path.stat().st_size == 0:
        raise RuntimeError(f"File is missing or empty: {path.name}")
    return path

Each function does one job and has a name that says what it is:

  • validate_inputs finds the layer, checks the fields, reads the template and checks that it is valid XML. It returns what the pipeline needs, or raises an error that says what is wrong.
  • make_layout builds a layout from the template and checks that the required items are there.
  • safe_filename makes a name safe for a file.
  • export_layout exports a PDF and then checks the file. If the export fails, or the file is missing or empty, it raises an error. Raising an error is better than returning a code that someone forgets to look at.

Small functions are easier to test. You can run safe_filename("North/West") on its own in the console and see North_West.

Step 4: The pipeline

# Step 4: the pipeline. Anything unexpected is written to the log, and the script carries on with the next map.
results = []
logger.info("Map generation started. QGIS %s", Qgis.version())
try:
    regions, document = validate_inputs()
    logger.info("Inputs are valid: %s features", regions.featureCount())

    seen = set()
    for feature in sorted(regions.getFeatures(), key=lambda f: f[config["name_field"]]):
        name = feature[config["name_field"]]
        filename = safe_filename(name) + ".pdf"
        try:
            if filename in seen:
                raise ValueError(f"Duplicate file name: {filename}")
            seen.add(filename)
            layout = make_layout(document)
            layout.itemById("map_title").setText(feature[config["title_field"]])
            QgsExpressionContextUtils.setProjectVariable(project, "focus_name", name)
            extent = feature.geometry().boundingBox()
            extent.scale(config["padding"])
            layout.itemById("main_map").zoomToExtent(extent)
            export_layout(layout, output_folder / filename)
            logger.info("Exported %s", filename)
            results.append((name, "ok"))
        except Exception as error:
            logger.exception("Map %s failed", name)
            results.append((name, f"failed: {error}"))
except Exception:
    logger.exception("Map generation stopped because of an error")
    raise
finally:
    logger.info("Map generation finished.")

Read this from the outside in.

The outer try is for problems with the whole run, such as a missing layer. logger.exception writes the error and its traceback to the log, and then raise lets the error continue, so that the failure is not hidden. The finally part always writes "Map generation finished", whatever happened.

The inner try is inside the loop and is for problems with one map. If one corporation fails, the script writes the failure to the log, notes it in results and carries on with the next. This is important for a big job. Without it, one bad feature out of five hundred stops everything.

seen is a set of the file names already used. If two features had the same name, the second would overwrite the first without anyone noticing, so the script raises an error for a duplicate.

Step 5: A summary

# Step 5: a summary for the person running the script
failed = [r for r in results if r[1] != "ok"]
print(f"{len(results) - len(failed)} of {len(results)} maps exported to {output_folder}")
for name, status in results:
    print(f"  {name}: {status}")
print("Log file:", output_folder / "logs" / "map_generation.log")

The script ends with a short report: how many maps were exported, the status of each, and where the log is. Run it:

5 of 5 maps exported to ...\part-c\output\pipeline
  Central: ok
  East: ok
  North: ok
  South: ok
  West: ok
Log file: ...\part-c\output\pipeline\logs\map_generation.log

Here it is in the console:

The QGIS Python Console after running the pipeline: 5 of 5 maps exported with each corporation marked ok, and the path of the log file
Figure 17.1. The Python Console after running the pipeline.

Now open the log file, output/pipeline/logs/map_generation.log. It holds one line for each event:

INFO Map generation started. QGIS 3.44.13-Solothurn
INFO Inputs are valid: 5 features
INFO Exported Central.pdf
INFO Exported East.pdf
INFO Exported North.pdf
INFO Exported South.pdf
INFO Exported West.pdf
INFO Map generation finished.

Every line also starts with the date and time. The log records the QGIS version too. When a map looks different in six months, you can see which version made it.

Production habits

These apply to any pipeline, not only this one.

  • No machine-specific paths. Everything is built from the project folder.
  • Check the inputs before the work. Fields, template, folders.
  • One setting, one place. The dictionary.
  • Record the QGIS version, the input data and the date. The log does part of this.
  • Handle bad data on purpose. Empty geometries, missing values and duplicate names are decided, not discovered.
  • Do not call a map publication-ready because the export worked. Look at it, with the checklist.
  • Run big jobs from a standalone QGIS environment that is set up for it, and not from the console on your laptop while you work.

Where to go from here

The full project puts this pipeline into practice. It adds a combined PDF made with the Atlas, PNG files, a CSV report, and a fully commented script that you can adapt to your own regions. Reading it after this lesson should feel familiar.

When it breaks

What you seeWhyWhat to do
ValueError: Layer not found: ...The name in config does not matchCopy the name from the Layers panel
FileNotFoundError: Template not found: ...The path in config is wrongCheck templates/ next to the project
ValueError: Template is not valid XML (line 1): ...The template file is damagedSave the template again from the layout
Every line in the log appears twiceOld handlers were not removedKeep the handler clean-up of Step 2
failed: Duplicate file name: East.pdfTwo features share a nameMake the names unique, or add the id to the file name
One map fails and the rest are fineA bad featureRead the log. It records the error and its traceback
The log is emptyThe logger or the folder is wrongCheck that output/pipeline/logs exists and that Step 2 ran

Make it your own

  1. Add "output_format": "png" to config. Which functions must change to use it?
  2. Make the pipeline write a small CSV file with the name and status of each map, as in the full project.
  3. Break the template on purpose, for example by renaming map_title. Run the script and read what the log says for each map.
  4. Add "padding": 1.4 and compare the maps. Where did you have to edit the code?

Common mistakes

  • Settings spread through the script, so changing the data means changing many lines.
  • Using print for things you will want to read later.
  • Letting one bad feature stop the whole job.
  • Letting two features write to the same file.
  • Never opening the log.
  • Calling the maps finished because the script said so.

Key takeaways

Next: The full project.