Create a Map Theme in PyQGIS

Creating a theme by hand means arranging the layer tree exactly as you want it and clicking Add theme. Doing it from Python is the same two steps, but it opens a door the interface does not: a script can rebuild the entire theme collection from a declaration, so the themes in a project become something you version rather than something you remember.

This recipe belongs to Map Themes & Layer Visibility in PyQGIS. It covers capturing the current state, constructing a theme record explicitly without touching the tree, running with no iface available, and making the whole thing safe to re-run.

Capture the state, or declare the recordThe capture route arranges the layer tree first and then snapshots it, which changes what the user sees. The declarative route builds a theme record from a list of layer records and inserts it, leaving the current canvas untouched. Both produce an identical entry in the project's theme collection.One of these disturbs the user's canvas; the other does notcapture the current stateset checkboxescanvas redrawssnapshotsimple, but the user watches layers flickerand the state must be restored afterwardsdeclare the recordbuild layer recordsinsert into collectionthe canvas never changes,so it is safe inside a pluginproject.mapThemeCollection()identical entry either way, saved with the project

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A project with several layers, ideally organised into groups so the group behaviour is visible.
  • If you intend to record styles as well as visibility, the named styles must exist on the layers before the theme is created.

Capture what is on screen now

The shortest route takes a snapshot of whatever the tree currently says.

from qgis.core import QgsProject, QgsMapThemeCollection

project = QgsProject.instance()
root = project.layerTreeRoot()
model = iface.layerTreeView().layerTreeModel()

record = QgsMapThemeCollection.createThemeFromCurrentState(root, model)
project.mapThemeCollection().insert("night", record)
project.setDirty(True)

Breakdown: createThemeFromCurrentState() is a static method on the collection class, not an instance method, which is why it takes the root and model as arguments rather than reading them from a project it does not have. setDirty(True) marks the project as modified so QGIS offers to save it; without that line a theme created by a script can be lost when the user closes the project believing nothing has changed. insert() replaces any existing theme of the same name in silence, which is exactly what you want when re-running a build script and exactly what you do not want when a user names a new theme carelessly — check mapThemes() first if the name came from a dialog.

Build a theme without touching the canvas

Inside a plugin, rearranging the user's layer tree to capture a theme is rude and hard to undo cleanly. QgsMapThemeCollection.MapThemeRecord can be assembled directly instead.

from qgis.core import QgsMapThemeCollection

record = QgsMapThemeCollection.MapThemeRecord()

for name, style in (("basemap", ""), ("buildings", "by age"), ("flood extent", "1 in 100")):
    layer = project.mapLayersByName(name)[0]
    layer_record = QgsMapThemeCollection.MapThemeLayerRecord(layer)
    layer_record.usingCurrentStyle = bool(style)
    layer_record.currentStyle = style
    layer_record.usingLegendItems = False
    record.addLayerRecord(layer_record)

project.mapThemeCollection().insert("flood risk", record)

Breakdown: A MapThemeLayerRecord holds the layer plus four flags, and the ones that matter here are usingCurrentStyle — meaning "this theme pins a named style" — and usingLegendItems, which when False means "show every legend entry" rather than a recorded subset. Only layers present in the record are visible when the theme is applied; every layer you omit is hidden, so this list is the complete definition of the map rather than a set of additions. Building the record this way never touches the layer tree, so the canvas the user is looking at is undisturbed until they choose the theme themselves.

The style name must match an existing entry in the layer's style manager. A name that does not exist is not an error — the layer simply keeps its current style — so validate before inserting:

if style and style not in layer.styleManager().styles():
    raise ValueError(f"{name} has no style called {style!r}")

Breakdown: Failing here, at build time, converts a silent wrong-looking map into an immediate and precise complaint. styleManager().styles() returns the list of names, with the default one usually called default unless it has been renamed.

Inside a map theme layer recordEach layer record names one layer and carries flags. Using current style pins a named style from the layer's style manager. Using legend items restricts the theme to a recorded subset of legend entries. Any layer with no record at all is hidden when the theme is applied.Omission is how a theme hides somethingMapThemeLayerRecordlayerwhich layer this isusingCurrentStylepin a named style?currentStylethe style's nameusingLegendItemsa subset of classes?has a recorddrawn when the theme appliesno recordhidden — there is no third state

Running without iface

A standalone script has no interface object, so the model has to be built.

from qgis.core import QgsApplication, QgsProject, QgsLayerTreeModel

qgs = QgsApplication([], False)
qgs.initQgis()

project = QgsProject.instance()
project.read("/data/projects/flooding.qgz")

root = project.layerTreeRoot()
model = QgsLayerTreeModel(root)
model.setFlag(QgsLayerTreeModel.ShowLegend, True)

record = QgsMapThemeCollection.createThemeFromCurrentState(root, model)
project.mapThemeCollection().insert("automated", record)
project.write()

qgs.exitQgis()

Breakdown: QgsLayerTreeModel(root) builds the model over the project's existing tree; it must be kept alive while the theme is created, so assigning it to a local that stays in scope matters in a way it does not in the console. setFlag(ShowLegend, True) is what causes the model to create legend nodes at all — without it a theme captured headlessly records visibility but no legend-item state, and applying it later ticks every class regardless of what was intended. project.write() with no argument saves back to the path it was read from.

Make the script idempotent

A build script that is safe to run twice is worth the extra six lines, because it can be wired into project load or a scheduled job.

WANTED = {
    "base": ["basemap", "boundaries"],
    "flood risk": ["basemap", "flood extent", "buildings"],
}

collection = project.mapThemeCollection()
for existing in list(collection.mapThemes()):
    if existing not in WANTED:
        collection.removeMapTheme(existing)

for theme_name, layer_names in WANTED.items():
    record = QgsMapThemeCollection.MapThemeRecord()
    for layer_name in layer_names:
        matches = project.mapLayersByName(layer_name)
        if not matches:
            raise LookupError(f"theme {theme_name!r} wants missing layer {layer_name!r}")
        record.addLayerRecord(
            QgsMapThemeCollection.MapThemeLayerRecord(matches[0])
        )
    collection.insert(theme_name, record)

Breakdown: Iterating over list(collection.mapThemes()) rather than the live sequence avoids mutating a collection while walking it. Removing themes that are no longer declared is what makes the script the source of truth rather than an additive process that accumulates stale entries. Raising on a missing layer converts the silent failure mode — a theme that shows less than it should — into a stack trace naming both the theme and the layer.

Record only some of a layer's classes

A theme can show part of a categorized layer. That is the difference between three filtered copies of an incidents layer and one layer appearing three ways.

from qgis.core import QgsMapThemeCollection

layer = project.mapLayersByName("incidents")[0]
renderer = layer.renderer()

wanted = {"flooding", "subsidence"}
rule_keys = [
    category.uuid()
    for category in renderer.categories()
    if category.value() in wanted
]

layer_record = QgsMapThemeCollection.MapThemeLayerRecord(layer)
layer_record.usingLegendItems = True
layer_record.checkedLegendItems = rule_keys
record.addLayerRecord(layer_record)

Breakdown: checkedLegendItems holds legend node keys, not labels — for a categorized renderer each category's uuid() is that key, and for a rule-based renderer it is the rule's ruleKey(). Setting usingLegendItems = True is what makes the list mean anything; leave it False and the keys are stored and ignored, which is the usual reason a legend-filtered theme shows everything. Because the keys are generated when the renderer is built, re-classifying the layer invalidates them, so themes of this kind belong in the same script that defines the renderer.

The reward is a legend that matches the map without extra work. Unticked classes vanish from both the canvas and any legend that follows the theme, so a "flood incidents only" deliverable needs no separate layer, no subset string and no second style.

When a theme is the wrong tool

Themes describe what is visible, and nothing else. They do not store an extent, a scale, a page size or an output path. If two deliverables differ only in where the map is looking, the difference belongs on the layout map item or in an atlas, not in a theme.

They are also a poor fit when the difference is really about data rather than presentation. Two themes showing the same layer with different subset strings will fight each other, because the subset string lives on the layer and a theme cannot record it. In that case duplicate the layer — QGIS is happy to hold two layers over one file — give each its own filter and style, and let the themes choose between them. The general rule holds up well in practice: if the thing that differs is a property of the tree or the style, use a theme; if it is a property of the data or the page, use something else.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.16 LTR3.7QgsMapThemeCollection and MapThemeRecord present; API stable since 3.0.
3.22 LTR3.9MapThemeLayerRecord exposes expandedLegendItems for legend-tree state.
3.28 LTR3.9removeMapTheme() and renameMapTheme() available.
3.34 LTR3.12Baseline for this page.
3.40+3.12Theme records can carry per-theme layer opacity overrides.

Troubleshooting

  • AttributeError: iface is not defined. The script is running standalone. Construct QgsLayerTreeModel(root) yourself.
  • The theme records visibility but not legend classes. The model was created without ShowLegend. Set the flag before capturing.
  • The theme silently overwrote another. insert() replaces by name. Check mapThemes() before inserting a user-supplied name.
  • Applying the theme hides a layer you expected. That layer has no record. A theme is a complete definition, not a set of changes.
  • The pinned style is ignored. The style name does not exist in that layer's style manager. Compare against layer.styleManager().styles().
  • The theme disappears after closing the project. The project was never saved. Call project.setDirty(True) in the GUI, or project.write() headlessly.

Conclusion

Capture with createThemeFromCurrentState() when a quick snapshot is fine, and build a MapThemeRecord by hand when the script must not disturb what the user is looking at. Remember that omission means hidden, that the model needs ShowLegend to record legend classes, and that a rebuild script which removes undeclared themes is the version that stays correct.

Frequently Asked Questions

Can I rename a theme without recreating it? Yes — collection.renameMapTheme(old, new) since QGIS 3.28. Anything referring to the old name, including a layout map item following it, needs updating separately.

How do I copy themes between projects? Read both projects into separate QgsProject instances and copy the records across, matching layers by name rather than id, because ids differ between projects. Layers absent from the target must be resolved or skipped explicitly.

Does a theme store layer order? No. Draw order is a property of the layer tree and is shared by every theme in the project. If two deliverables need different draw orders they need different projects, or a script that reorders the tree before export.

Can a theme include a layer that is not in the project? No. Records hold references to loaded layers, so add the layer to the project first, then build the record.