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.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A layout with a map item — see Automated Map Layout Generation with PyQGIS for creating one, or Exporting Multiple QGIS Layouts to PDF for the export side.
- Layers already styled, because the legend renders whatever symbology they currently have.
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.
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.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | Full legend API; setLegendFilterOutAtlas present. |
| 3.28 LTR | 3.9 | Behaviour matches this page. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | QgsLegendStyle.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, orsetResizeToContents(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.