Map Themes & Layer Visibility in PyQGIS
A map theme is a saved answer to the question "what should be visible right now, and how should it look". It records which layers are checked, which groups are expanded, which style each layer is using, and which legend items are ticked. One project can hold a dozen of them — base, flood risk, land use, night — and switch between them instantly, on the canvas or inside a print layout.
That makes themes the most under-used feature in QGIS automation. A single project with five themes replaces five near-identical project files, and a layout whose map item follows a theme produces five different maps from one template. This guide sits inside PyQGIS Cartography & Data Visualization and covers the whole chain: the layer tree that visibility actually lives in, the theme collection that snapshots it, scale ranges that override both, and how a layout map item is told to follow a theme.
Visibility lives in the layer tree, not the layer
The single most common PyQGIS mistake in this area is looking for a visibility property on QgsVectorLayer. There is not one. A layer is a data source with a style; whether it is drawn is a property of the node representing it in the project's layer tree.
from qgis.core import QgsProject
project = QgsProject.instance()
root = project.layerTreeRoot()
layer = project.mapLayersByName("land use")[0]
node = root.findLayer(layer.id())
node.setItemVisibilityChecked(False)
Breakdown: layerTreeRoot() returns the root group of the tree, and findLayer() locates the node for a given layer id anywhere beneath it, including inside nested groups. setItemVisibilityChecked() sets the checkbox; the older setVisible() still exists as an alias and is worth avoiding because it reads as though it belongs to the layer. There is no repaint call here — the canvas listens to the tree and redraws itself.
Groups complicate this usefully. A layer can be checked while its parent group is not, in which case it is not drawn. node.isVisible() answers the question you usually mean — is this actually being drawn — by walking up through the ancestors, while itemVisibilityChecked() reports only this node's own checkbox. Scripts that toggle a layer and then wonder why nothing appeared have almost always found a group unchecked further up.
Creating a theme from the current state
QgsMapThemeCollection lives on the project and holds every theme by name.
from qgis.core import QgsProject, QgsMapThemeCollection
project = QgsProject.instance()
root = project.layerTreeRoot()
model = iface.layerTreeView().layerTreeModel()
record = QgsMapThemeCollection.createThemeFromCurrentState(root, model)
project.mapThemeCollection().insert("flood risk", record)
Breakdown: createThemeFromCurrentState() needs both the tree root and the tree model, because the checked state of individual legend items — the categories of a categorized renderer, for instance — is model state rather than tree state. insert() overwrites an existing theme of the same name without asking, which makes updating a theme a one-liner and makes an accidental overwrite equally easy. In a headless script there is no iface, and the model must be constructed explicitly with QgsLayerTreeModel(root).
Applying a theme is the mirror image:
project.mapThemeCollection().applyTheme(
"flood risk", root, model
)
Breakdown: applyTheme() mutates the layer tree and the styles in place, so the canvas afterwards is exactly what the theme recorded. Anything the theme does not mention — a layer added since it was created — keeps its current state, which is why a stale theme tends to show new layers unexpectedly rather than hiding them.
What a theme does and does not survive
Themes reference layers by id. Remove a layer from the project and every theme that mentioned it quietly loses that entry; re-add the same file and it gets a new id, so the theme does not pick it up again. This is the mechanism behind most "my themes broke" reports after a project reorganisation.
Named layer styles are referenced by name, and that is more forgiving: rename a style and the theme falls back to the current one rather than failing. The practical rule is to create the styles first, then the themes, and to treat the theme collection as something to rebuild from a script rather than to hand-maintain across a project's life. A short function that clears the collection and rebuilds every theme from a list of layer names is a hundred times more robust than a project whose themes were clicked into place two years ago.
THEMES = {
"base": ["basemap", "boundaries"],
"flood risk": ["basemap", "flood extent", "buildings"],
"land use": ["basemap", "land use", "boundaries"],
}
collection = project.mapThemeCollection()
for name in collection.mapThemes():
collection.removeMapTheme(name)
for theme_name, visible in THEMES.items():
for tree_layer in root.findLayers():
tree_layer.setItemVisibilityChecked(tree_layer.layer().name() in visible)
collection.insert(
theme_name,
QgsMapThemeCollection.createThemeFromCurrentState(root, model),
)
Breakdown: findLayers() returns every layer node in the tree in drawing order, so the inner loop sets each checkbox explicitly rather than only turning things on — which matters, because a layer left checked from the previous iteration would leak into the next theme. Clearing the collection first means the script is idempotent: run it twice and the result is identical, which is what makes it safe to call on every project load.
Scale-based visibility overrides everything
A layer can be checked, its group checked, its theme applied — and still not draw, because it is outside its scale range. That is a separate mechanism and it wins.
layer.setScaleBasedVisibility(True)
layer.setMinimumScale(50000) # not drawn when zoomed out beyond 1:50 000
layer.setMaximumScale(1000) # not drawn when zoomed in past 1:1 000
Breakdown: The naming is genuinely confusing and catches everyone once: minimum scale is the largest denominator, meaning the most zoomed-out limit. A layer with setMinimumScale(50000) disappears when you zoom out past 1:50 000. Read them as "the zoomed-out limit" and "the zoomed-in limit" and the code stops being ambiguous. Both are stored on the layer, not the tree, so they apply in every theme and in every layout unless a layout map item overrides them.
Scale ranges are the correct tool for a multi-scale basemap — coarse boundaries at national scale, detailed parcels only when the reader is close enough for them to mean something — and they cost nothing at render time because the layer is skipped entirely rather than drawn and discarded.
Themes inside a print layout
A layout map item can follow a theme rather than the canvas, and that is what turns one template into a series.
layout = project.layoutManager().layoutByName("A3 landscape")
map_item = layout.itemById("main map")
map_item.setFollowVisibilityPreset(True)
map_item.setFollowVisibilityPresetName("flood risk")
layout.refresh()
Breakdown: setFollowVisibilityPreset(True) is what disconnects the map item from the canvas; without it the name is stored and ignored. The API still uses the word preset, which was the feature's original name before it was renamed to theme in the interface — the two words mean the same thing throughout this part of the API. itemById() finds the item by the id you set in the layout's item properties, which is far more durable than positional lookup and worth setting on every item a script will touch.
Combined with an atlas, this gives two independent axes: the atlas iterates over features, the theme decides what is shown, and a loop over themes producing one atlas each generates a full document set from a single layout.
Legend entries, and the state that lives in the model
A theme records more than checkboxes on layers. It also records which legend entries are ticked — the individual categories of a categorized renderer, the classes of a graduated one, the bands of a rule-based renderer. That is why the theme API insists on a layer tree model: the tree knows about layers, the model knows about the rows underneath them.
from qgis.core import QgsLayerTreeModelLegendNode
node = root.findLayer(layer.id())
for legend_node in model.layerLegendNodes(node):
label = legend_node.data(0) # the category label
checked = legend_node.data(QgsLayerTreeModelLegendNode.NodeTypeRole)
print(label, checked)
Breakdown: layerLegendNodes() returns one node per legend row, and each row carries its own checked state when the renderer supports filtering by legend. Unticking a category hides those features on the canvas as well as removing the row from the legend, which is a surprisingly clean way to build a "residential only" theme without touching the renderer. Because this state is model state, a headless script that constructs its own QgsLayerTreeModel must call model.setFlag(QgsLayerTreeModel.ShowLegend) before the nodes exist to be read.
The practical consequence is worth stating plainly: two themes can share one layer with one renderer and still show different subsets of its features. For a layer of incidents categorized by type, that is often better than three filtered copies of the same layer, because the classification, colours and labels are defined once.
A worked pattern: one project, many deliverables
The combination that repays the effort is a project holding every layer, a set of themes describing each deliverable, and a script that walks the themes producing output.
from qgis.core import QgsProject, QgsLayoutExporter
project = QgsProject.instance()
collection = project.mapThemeCollection()
layout = project.layoutManager().layoutByName("A3 landscape")
map_item = layout.itemById("main map")
map_item.setFollowVisibilityPreset(True)
for theme_name in collection.mapThemes():
map_item.setFollowVisibilityPresetName(theme_name)
layout.refresh()
exporter = QgsLayoutExporter(layout)
exporter.exportToPdf(
f"/data/output/{theme_name.replace(' ', '_')}.pdf",
QgsLayoutExporter.PdfExportSettings(),
)
Breakdown: mapThemes() returns the theme names in the order they were inserted, so a deliberate insertion order gives a deliberate output order without a sort. layout.refresh() between the theme change and the export is mandatory — the map item caches its render, and skipping the refresh exports the previous theme with the new filename, which is the kind of bug that survives review because every file exists and looks plausible. Sanitising the theme name into a filename is worth doing centrally rather than trusting that nobody will ever name a theme with a slash in it.
Extending this to an atlas is a matter of nesting: for each theme, set the atlas up and iterate its features, so n themes and m atlas features produce n × m pages from one layout. The atlas export guide covers the inner loop.
Reading the state back when something is missing
When a layer refuses to appear, ask the three gates in order rather than guessing. This diagnostic prints everything relevant in one pass.
def why_hidden(layer, canvas_scale):
node = root.findLayer(layer.id())
if node is None:
return "not in the layer tree at all"
if not node.itemVisibilityChecked():
return "its own checkbox is off"
if not node.isVisible():
return "an ancestor group is unchecked"
if layer.hasScaleBasedVisibility():
if canvas_scale > layer.minimumScale() or canvas_scale < layer.maximumScale():
return f"outside its scale range at 1:{canvas_scale:.0f}"
return "visible — look at the extent, the CRS or the filter next"
Breakdown: Ordering the checks from cheapest to subtlest means the answer usually arrives on the first or second line. The scale comparison reads backwards on purpose: minimumScale() is the largest denominator, so being above it means being zoomed out too far. Returning a sentence rather than a boolean makes this function useful inside a plugin's diagnostics panel as well as in the console, and it is the sort of helper worth keeping in a small utilities module alongside the rest of a project's scripts.
Keeping themes in step with a changing project
Themes rot quietly. A layer is replaced with a corrected version, a group is reorganised, a style is renamed — and nothing errors, because a theme that cannot find what it recorded simply leaves that part of the tree alone. The result is a theme that looks applied but is showing whatever happened to be checked beforehand.
Three habits keep that from happening. First, rebuild the collection from code on every project load rather than editing themes by hand, so the definition lives in a file you can diff. Second, validate after applying: compare the set of visible layer names against what the theme was supposed to show, and raise rather than export a wrong map.
def assert_theme(theme_name, expected_names):
collection.applyTheme(theme_name, root, model)
shown = {n.layer().name() for n in root.findLayers() if n.isVisible()}
missing = set(expected_names) - shown
extra = shown - set(expected_names)
if missing or extra:
raise RuntimeError(f"{theme_name}: missing {missing}, unexpected {extra}")
Breakdown: Using isVisible() rather than itemVisibilityChecked() is deliberate — it is the question the reader of the map will effectively be asking, and it catches the unchecked-parent-group case that a checkbox test would miss. Comparing in both directions matters just as much: a theme that has quietly gained a layer is as wrong as one that has lost one, and only the extra set catches it.
Third, name themes for what they show rather than for who asked for them. Flood risk survives a reorganisation; Tuesday meeting version 2 does not, and the person who inherits the project will not know whether it is safe to delete.
Themes in a plugin interface
Exposing themes through a plugin is a small amount of code and a large amount of usability, because it lets a user switch a whole map configuration from one control rather than fifteen checkboxes.
from qgis.PyQt.QtWidgets import QComboBox
class ThemeSwitcher(QComboBox):
def __init__(self, iface, parent=None):
super().__init__(parent)
self.iface = iface
self.project = QgsProject.instance()
self.reload()
self.project.mapThemeCollection().mapThemesChanged.connect(self.reload)
self.currentTextChanged.connect(self.apply)
def reload(self):
current = self.currentText()
self.blockSignals(True)
self.clear()
self.addItems(self.project.mapThemeCollection().mapThemes())
if current in self.project.mapThemeCollection().mapThemes():
self.setCurrentText(current)
self.blockSignals(False)
def apply(self, name):
if not name:
return
root = self.project.layerTreeRoot()
model = self.iface.layerTreeView().layerTreeModel()
self.project.mapThemeCollection().applyTheme(name, root, model)
Breakdown: mapThemesChanged fires whenever a theme is added, removed or renamed, so the combo box stays in step with the Map Themes toolbar without polling. blockSignals() around the repopulation is what stops clear() emitting a change that applies an empty theme name and blanks the map — a classic Qt trap that appears the first time anyone wires a combo box to an action. Restoring the previously selected name after reloading keeps the user's choice through an unrelated theme edit, which is the difference between a control that feels solid and one that feels like it resets at random.
The same signal is useful outside a widget: a plugin that exports on a schedule can connect to it and invalidate a cached list of deliverables whenever the project's themes change.
Key takeaways
- Visibility is a property of the layer tree node, not the layer;
root.findLayer(layer.id())is how you reach it. node.isVisible()accounts for ancestor groups;itemVisibilityChecked()does not.QgsMapThemeCollection.createThemeFromCurrentState()needs the tree model as well as the root, because legend-item state lives in the model.insert()silently overwrites, which makes rebuilding the whole collection from a script the safest way to maintain themes.- Themes reference layers by id, so removing and re-adding a layer breaks them.
- Minimum scale is the zoomed-out limit; the naming trips up everyone once.
- A layout map item follows a theme only when
setFollowVisibilityPreset(True)is set as well as the name.
Frequently Asked Questions
Why does layer.setVisible() not exist?
Because visibility is not a property of the layer. Several layers can share one data source and one style while appearing in different groups with different checked states, so the state belongs to the tree node. Use root.findLayer(layer.id()).setItemVisibilityChecked(...).
Can I create a theme without the GUI?
Yes. Build a QgsLayerTreeModel(root) explicitly instead of taking one from iface, and everything else is identical. That is exactly what a headless script needs.
Do themes store the map extent? No. A theme records what is visible and how it looks, never where the map is looking. Extent belongs to the canvas or to the layout map item, and an atlas coverage layer is the usual way to drive it.
How do I make one layer appear in every theme? Check it before creating each theme. There is no "always visible" flag; a theme is a complete snapshot, so a layer omitted from one theme is hidden by it.
Can a theme change a layer's style? Yes, that is one of its main uses. Save several named styles on the layer, switch to the one you want, then capture the theme — the style name is recorded with the visibility.
Are themes saved with the project?
Yes, in the project file. They travel with a .qgz and are lost if you rebuild the project from scratch, which is the practical argument for generating them from a script that lives in version control.
Related
- PyQGIS Cartography & Data Visualization — the section this topic belongs to
- Create a Map Theme in PyQGIS
- Toggle Layer Visibility and Legend Entries in PyQGIS
- Set Scale-Based Visibility in PyQGIS
- Apply a Map Theme to a Layout Map in PyQGIS
- Organise Layer Tree Groups in PyQGIS
- Automated Map Layout Generation in PyQGIS