Add a Legend to a Print Layout in PyQGIS

A legend is the part of an automated map that most often looks automated. Left to its defaults it lists every layer in the project, including the basemap and the three scratch layers nobody meant to publish, in the order the layer tree happens to hold them, with the file names as titles. Ten lines of PyQGIS turn that into a curated legend that says exactly what the map shows.

This recipe belongs to Automated Map Layout Generation with PyQGIS. It covers adding the legend item, linking it to a map, filtering it to what is actually drawn, renaming and removing entries, and keeping it consistent while an atlas iterates.

What the legend is reading fromA layout page holds a map item and a legend item. The legend is linked to the map, so filtering by map content restricts it to layers visible in that map's extent and scale. Without the link the legend lists every layer in the project layer tree, including layers the map does not show.Link the legend to the map, or it lists the whole projectthe layout pagemap itemlegendWardsParksRoadslinked + filtered by maponly layers this map drawsscale-hidden rules omitted toounlinkedevery layer in the projectincluding scratch and basemap

Prerequisites

Add and place the legend

from qgis.core import QgsProject, QgsLayoutItemLegend, QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes

project = QgsProject.instance()
layout = project.layoutManager().layoutByName("Ward map")
map_item = layout.itemById("main_map")

legend = QgsLayoutItemLegend(layout)
legend.setId("main_legend")
legend.setTitle("Legend")
legend.setLinkedMap(map_item)

layout.addLayoutItem(legend)
legend.attemptMove(QgsLayoutPoint(210, 20, QgsUnitTypes.LayoutMillimeters))
legend.attemptResize(QgsLayoutSize(70, 100, QgsUnitTypes.LayoutMillimeters))

Breakdown: setLinkedMap() is the call that matters — it ties the legend to a specific map item so that filtering, scale-dependent rules and any atlas-driven layer visibility are taken from that map rather than the project. addLayoutItem() must come before positioning, because an item not yet in the layout has no coordinate space to move within. attemptMove() and attemptResize() are named for a reason: a locked or referenced item may end up somewhere slightly different, and the return is silent. Setting an id makes the item findable later with itemById(), which is how a script re-runs against an existing layout without creating duplicates.

Show only what the map draws

legend.setLegendFilterByMapEnabled(True)
legend.setLegendFilterOutAtlas(True)
legend.setAutoUpdateModel(True)
legend.updateLegend()

Breakdown: setLegendFilterByMapEnabled(True) restricts entries to layers with features inside the linked map's extent — a ward map of an area with no parks stops advertising a Parks symbol nobody can see. setLegendFilterOutAtlas(True) extends that to the current atlas feature, so a per-ward map's legend reflects that ward. setAutoUpdateModel(True) keeps the legend synchronised with the project layer tree, which is what you want until the moment you start curating entries by hand — at which point it must be turned off, because an auto-updating model discards manual edits on the next refresh.

Curate the entries

legend.setAutoUpdateModel(False)

model = legend.model()
root = model.rootGroup()

for layer_node in list(root.findLayers()):
    layer = layer_node.layer()
    if layer is None or layer.name().startswith("scratch_"):
        root.removeChildNode(layer_node)
        continue
    if layer.name() == "wards_2026":
        layer_node.setName("Ward boundaries")

legend.adjustBoxSize()
legend.refresh()

Breakdown: Turning off the auto-update model is mandatory before editing, otherwise every change is reverted on the next refresh. findLayers() returns the layer nodes in the legend's own tree — a copy of the project's layer tree, so removing a node removes the legend entry and leaves the project untouched. Wrapping it in list() matters because removing nodes while iterating the live collection skips entries. setName() on the node renames the legend entry only, which is how a legend reads "Ward boundaries" while the layer stays wards_2026 for every script that looks it up by name. adjustBoxSize() shrinks the frame to the content, so the box does not float over the map with empty space below the last entry.

Default legend against a curated oneThe default legend lists five entries using raw layer names including two scratch layers and a basemap, inside an oversized frame. The curated legend shows three entries with readable titles, no scratch layers, and a frame shrunk to the content.The same project, before and after ten lines of curationdefaultwards_2026parks_final_v3scratch_bufferscratch_clip2OSM Standardoversized frame, raw namescuratedWard boundariesPublic parksRoadsbox shrunk to content, names readable

Control the typography and columns

from qgis.core import QgsLegendStyle
from qgis.PyQt.QtGui import QFont

legend.setStyleFont(QgsLegendStyle.Title, QFont("Noto Sans", 11, QFont.Bold))
legend.setStyleFont(QgsLegendStyle.Subgroup, QFont("Noto Sans", 9, QFont.Bold))
legend.setStyleFont(QgsLegendStyle.SymbolLabel, QFont("Noto Sans", 8))

legend.setColumnCount(2)
legend.setSplitLayer(True)
legend.setEqualColumnWidth(True)
legend.setSymbolWidth(6)
legend.setSymbolHeight(3)
legend.adjustBoxSize()

Breakdown: Each part of a legend has its own style slot, so a title, group headings and symbol labels can be sized independently. setColumnCount(2) with setSplitLayer(True) allows a single layer's many classes to flow across columns rather than forcing each layer into one — essential for a graduated renderer with nine classes on a portrait page. Symbol width and height are in millimetres and default to values tuned for screen; shrinking them slightly is usually what makes a dense legend fit.

Keep it stable through an atlas

An atlas redraws the legend for every feature, and a legend that resizes per page makes a document that looks unsettled.

atlas = layout.atlas()
legend.setResizeToContents(False)
legend.attemptResize(QgsLayoutSize(70, 100, QgsUnitTypes.LayoutMillimeters))

Breakdown: setResizeToContents(False) fixes the frame, so a ward with fewer visible classes still produces a legend box of the same size in the same place. Combined with setLegendFilterByMapEnabled(True), the entries still change per page while the layout does not shift — the combination that makes an atlas look designed rather than generated. Atlas iteration itself is covered in Generate an Atlas PDF in PyQGIS.

Place it against the map rather than the page

Positioning by absolute page coordinates works until the map item moves, the page size changes, or a portrait layout gains a landscape sibling. Anchoring the legend to a corner of the map keeps the relationship intact.

from qgis.core import QgsLayoutItem, QgsLayoutPoint, QgsUnitTypes

map_rect = map_item.rect()
map_pos = map_item.pagePos()

margin = 4
legend.setReferencePoint(QgsLayoutItem.UpperRight)
legend.attemptMove(
    QgsLayoutPoint(
        map_pos.x() + map_rect.width() - margin,
        map_pos.y() + margin,
        QgsUnitTypes.LayoutMillimeters,
    )
)

Breakdown: setReferencePoint() decides which corner of the legend the coordinate refers to. With UpperRight, the position given is the legend's top-right corner, so the box grows leftward and downward as entries are added — exactly what you want for a legend tucked inside the map's top-right corner, and the opposite of the default, which would push it off the page. Deriving the coordinate from the map item's own position and size rather than from constants means a layout whose map is resized keeps its legend in the corner.

For a layout that will be reused at several page sizes, go one step further and compute the margin as a fraction of the map's width. The same arithmetic then places a scalebar, a north arrow and a title block — the pieces covered in Add a Scalebar and North Arrow in PyQGIS — and a single helper that anchors any item to any corner of the map removes most of the fiddly coordinate work from layout scripts.

The reference point decides which way the box growsWith an upper-left reference point the legend grows to the right and off the page edge as entries are added. With an upper-right reference point the same coordinate keeps the box's top-right corner fixed in the map's corner, so it expands inward and stays on the page.Same coordinate, opposite outcomereference point: upper leftmap itemlegend growsrightwardreference point: upper rightmap item← legend growsstays in the cornerDerive the coordinate from the map item's position so a resized map keeps its furniture

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.22 LTR3.9Full legend API; setLegendFilterOutAtlas present.
3.28 LTR3.9Behaviour matches this page.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsLegendStyle.Style members are scoped; adds per-item text formats alongside fonts.

Troubleshooting

  • The legend lists layers the map does not show. No linked map, or filtering not enabled. Set both.
  • Manual renames disappear. setAutoUpdateModel(True) is still set; the model regenerated from the project tree.
  • The legend is empty. Filtering by map is on and the map extent contains no features from any layer — often because the map item's extent was never set.
  • Entries appear in the wrong order. The legend follows the project layer tree. Reorder the layers, or reorder the nodes in the legend's own model.
  • The box overlaps the map. adjustBoxSize() was not called after editing, or setResizeToContents(False) fixed it at the wrong size.
  • Fonts differ between screen and PDF. The font is not installed on the exporting machine. Embed fonts on export, or use a font present in the container — see Run PyQGIS in a Docker Container.

Conclusion

A good automated legend is four decisions: link it to the map item, filter it to what that map draws, turn off the auto-update model before curating names and removing entries, and fix the frame size when an atlas will iterate. Everything else — columns, fonts, symbol sizes — is typography you set once and reuse across every layout in the project.

Frequently Asked Questions

How do I add a layer to the legend that is not in the map? Turn off the auto-update model and add the node yourself with model().rootGroup().addLayer(layer). Use it sparingly: a legend entry for something the map does not draw is a promise the map breaks.

Can I show only some classes of a layer? Yes — find the layer node and remove individual symbol items via QgsMapLayerLegendUtils.setLegendNodeOrder(), or hide them by index. It is fiddly; often the cleaner answer is a rule-based renderer whose rules are the classes you want shown.

Why does my graduated legend show the raw class ranges? Those are the renderer's labels. Set friendlier ones on the renderer's range objects before building the legend — see Create a Choropleth Map in PyQGIS.

How do I add a title above the legend but below the frame?setTitle() handles it, and the title's font comes from the QgsLegendStyle.Title slot. An empty string removes it entirely.

Does the legend update if I restyle a layer afterwards? On the next refresh, yes — the symbols are read from the layer at render time. Renamed entries survive only while the auto-update model is off.