Organise the Layer Tree with Groups in PyQGIS

A project with forty layers and no groups is technically complete and practically unusable. Grouping is what turns a wall of names into a map somebody can navigate — base data, analysis outputs, annotation — and because the structure is part of the project file, a script that generates projects has to build it deliberately or hand the user the wall.

This recipe belongs to Working with QGIS Projects in PyQGIS. It covers the layer tree node API: creating groups, placing layers precisely, reordering, controlling visibility and expansion, and the mutually exclusive groups that turn a group into a radio-button switcher.

The layer tree is a tree of nodes, not a list of layersThe root node holds three children: a group named Base map containing two layer nodes, a group named Analysis containing one layer node and a nested subgroup, and one ungrouped layer node. Group nodes and layer nodes are different classes, and only layer nodes point at a registered map layer.Groups nest; layers are always leaveslayerTreeRoot()group: Base mapQgsLayerTreeGroupRoadsBuildingsQgsLayerTreeLayer nodeseach points at a registered layergroup: Analysismay contain further groupsRemoving a node hides the layer; it does not remove the layer from the project

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A project with a few layers already registered — see Add and Remove Layers from a Project in PyQGIS.
  • The qgis.core module; nothing here needs the GUI, so it works in headless scripts too.

Create groups and place layers in them

from qgis.core import QgsProject

project = QgsProject.instance()
root = project.layerTreeRoot()

base = root.insertGroup(0, "Base map")          # at the top
analysis = root.addGroup("Analysis")            # at the bottom

for layer in project.mapLayersByName("Roads") + project.mapLayersByName("Buildings"):
    node = root.findLayer(layer.id())
    if node:
        clone = node.clone()
        base.insertChildNode(0, clone)
        node.parent().removeChildNode(node)

Breakdown: insertGroup(index, name) places a group at a known position while addGroup(name) appends, and both return the group node you then work with. Moving an existing layer node is the part that surprises people: nodes cannot be reparented directly, so the idiom is clone-then-remove — clone() copies the node including its visibility and any custom properties, insertChildNode() puts the copy where you want it, and removing the original leaves exactly one node. The underlying map layer is never touched by any of this, which is why the layer keeps its id, styling and data.

For a layer you are adding fresh, skip the dance entirely by registering it without a legend entry and inserting it straight into the group:

project.addMapLayer(flood_extent, False)
analysis.insertLayer(0, flood_extent)

Breakdown: insertLayer() creates the tree node for you, so this is both shorter and cheaper than adding at the root and moving afterwards. Index 0 is the top of the group; addLayer() appends to the bottom. Remember that in QGIS the top of the panel draws last, which means the first child of the tree is the layer drawn over everything else.

Control visibility, expansion and exclusivity

Every node carries display state that the project file remembers.

base.setExpanded(False)                 # collapsed in the panel
base.setItemVisibilityChecked(True)     # the group's own checkbox

analysis.setItemVisibilityCheckedRecursive(False)   # uncheck the group and its children

scenarios = root.addGroup("Scenarios")
scenarios.setIsMutuallyExclusive(True)  # only one child visible at a time

Breakdown: setExpanded() affects only how the panel looks when the project opens — worth setting to False for a group of fifteen base layers nobody needs to see individually. The visibility calls are the checkboxes: the non-recursive form toggles just this node, while the recursive form pushes the state down to every descendant, which is what you want when switching a whole analysis group off. A mutually exclusive group behaves like a set of radio buttons: checking one child unchecks the others, which is the cleanest way to ship four modelled scenarios in one project without the user accidentally viewing two at once.

A mutually exclusive group behaves like radio buttonsOn the left a normal group has three scenario layers, two of which are checked at the same time, producing an overlapping and misleading map. On the right the same group set to mutually exclusive shows only one scenario checked; checking a different one automatically clears the previous choice.Stop the user from viewing two scenarios at onceordinary groupchecked — scenario A, 1 in 100checked — scenario B, 1 in 200unchecked — scenario Cmutually exclusive groupchecked — scenario A, 1 in 100cleared automatically — scenario Bcleared automatically — scenario C

Walk the tree

Reading the structure is as useful as building it — for a report of what a project contains, or to apply something to every layer in one group.

from qgis.core import QgsLayerTreeGroup, QgsLayerTreeLayer

def describe(node, depth=0):
    pad = "  " * depth
    for child in node.children():
        if isinstance(child, QgsLayerTreeGroup):
            print(f"{pad}[{child.name()}]")
            describe(child, depth + 1)
        elif isinstance(child, QgsLayerTreeLayer):
            state = "on" if child.isVisible() else "off"
            print(f"{pad}- {child.name()} ({state})")

describe(project.layerTreeRoot())

Breakdown: children() returns the immediate children in panel order, so recursion is the natural traversal. Distinguishing the two node classes by type is the standard approach — a group node has no layer, and calling layer() on it returns None. isVisible() on a layer node accounts for its parents: a checked layer inside an unchecked group reports as not visible, which is exactly the question you usually want answered. If you only need the layers and not the structure, root.findLayers() returns every layer node in the tree in one flat list.

Order layers deliberately

Draw order is tree order, and getting it wrong produces a map where the polygons hide the labels.

def move_to_top(root, layer_id):
    node = root.findLayer(layer_id)
    if not node:
        return
    clone = node.clone()
    root.insertChildNode(0, clone)
    node.parent().removeChildNode(node)

move_to_top(project.layerTreeRoot(), annotation_layer.id())

Breakdown: The same clone-and-remove idiom moves a node anywhere, including out of a group and up to the root. A useful convention for generated projects is to build the tree top-down in the order you want it drawn — annotation, points, lines, polygons, raster, base map — because inserting each new group at index 0 as you go leaves the structure correct without any later reordering. Where scripts get this wrong the symptom is characteristic: everything renders, and the most important layer is underneath.

Panel order is draw order, upside downThe layer at the top of the panel is drawn last and therefore appears above everything else on the map. Building a tree top down in the order annotation, points, lines, polygons, raster and base map produces the correct stacking, while adding each new layer at the top of the tree as it is created reverses it.Top of the panel means last to drawthe Layers panelAnnotation — index 0Survey pointsParcelsBase map — last indexthe rendered mapbase map drawn firstparcels over itpoints over thoseannotation on top

Build a whole structure in one pass

Generated projects benefit from a small helper that takes a description of the structure and applies it, rather than a long script of individual calls that is hard to review.

STRUCTURE = {
    "Annotation": ["Labels", "Notes"],
    "Analysis": ["Flood extent", "Affected parcels"],
    "Base map": ["Roads", "Buildings", "Terrain"],
}

def build_tree(project, structure):
    root = project.layerTreeRoot()
    for group_name, layer_names in structure.items():
        group = root.findGroup(group_name) or root.addGroup(group_name)
        for layer_name in layer_names:
            for layer in project.mapLayersByName(layer_name):
                existing = root.findLayer(layer.id())
                if existing:
                    group.insertChildNode(-1, existing.clone())
                    existing.parent().removeChildNode(existing)
                else:
                    group.addLayer(layer)
    return root

build_tree(QgsProject.instance(), STRUCTURE)

Breakdown: Because Python dictionaries preserve insertion order, the groups are created top to bottom in the order written — annotation first, base map last, which is the draw order a map wants. insertChildNode(-1, ...) appends to the end of the group, so layers keep the order they appear in the list. Handling both the "already in the tree" and "registered but not shown" cases in one function means the same helper works whether the layers were added by the script or were already in a template project. Keeping the structure as data rather than code also makes it reviewable: a colleague can check the map's organisation without reading any Python.

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9Full node API as described, including mutually exclusive groups.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical; the panel gained filtering and layer-tree search, neither of which changes these calls.

setItemVisibilityChecked() replaced the older setVisible() on tree nodes in QGIS 3.0; snippets using setVisible(Qt.Checked) are QGIS 2 code and will fail on any 3.x release.

Troubleshooting

  • findGroup() returns None for a group you can see. The name must match exactly, including case and any trailing space. Use root.findGroups() and print the names when in doubt.
  • A layer vanished after moving it. The original node was removed before the clone was inserted, or the clone was inserted into a node that was itself then removed. Insert first, remove second.
  • Unchecking a group did not hide its layers. setItemVisibilityChecked(False) on the group hides the group's contents on the canvas, but a per-layer check state is preserved underneath; use the recursive form if you want the children cleared too.
  • The panel order does not match what the script built. Check whether layers were added with addMapLayer(layer) — that always inserts at the root, on top of your carefully built structure.
  • A group looks empty but the layers are still in the project. Removing a tree node hides a layer without unregistering it. Remove the layer itself with removeMapLayer() if that is what you meant.
  • Nothing renders after a restructure. A mutually exclusive group with no checked child renders nothing at all. Check one explicitly after building it.

Conclusion

The layer tree is a separate structure from the layer registry, built from group and layer nodes. Create groups with addGroup() or insertGroup(), place new layers with insertLayer() after registering them without a legend entry, and move existing ones by cloning the node and removing the original. Set expansion, visibility and exclusivity deliberately — they are stored in the project and are most of what makes a generated project feel hand-made.

Frequently Asked Questions

How do I move a layer into a group without losing its style? Clone the tree node and remove the original. The style belongs to the map layer, not the node, so it is never affected by tree operations.

Can a group contain another group? Yes, to any depth. addGroup() on a group node creates a subgroup, and the recursion in the traversal example handles arbitrary nesting.

What is the difference between a layer node and a map layer? The map layer holds the data, style and id; the tree node is a pointer to it with panel state such as check status and expansion. One map layer has at most one node in a project.

How do I hide a layer from the panel but keep it rendering? You cannot — rendering follows the tree. What you can do is register the layer without a node so it is neither shown nor drawn, and use it purely as a data source for Processing.

Do groups affect performance? No. Grouping is presentation only; the renderer draws the same layers either way.