Add a Map Item and Set Its Extent in PyQGIS

The map item is the one part of a layout that has to be right. A title in the wrong font is a nuisance; a map showing the wrong extent, at a scale nobody chose, with a layer that happened to be switched off, is a wrong map — and in a batch of two hundred it is two hundred wrong maps.

This recipe belongs to Automated Map Layout Generation. It covers creating a map item, positioning it in page units, choosing between an extent and a scale, locking the layers and their styles, adding an overview frame, and the aspect-ratio behaviour that surprises everybody once.

The frame decides the extent, not the other way roundA map item occupies a rectangle on the page measured in millimetres. When an extent is requested that does not match the frame's proportions, QGIS keeps the requested extent fully visible and expands it in the shorter dimension to fill the frame. The map therefore shows more than was asked for, never less.You get at least the extent you asked for, and often moreA4 landscape pagemap item180 by 120 mmx, y set from the top leftrequested versus shownrequestedactually shownwidened to match the frame proportionsSet a scale instead when the exact scale matters more than the exact extent

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A project with layers, and either an existing layout or a willingness to create one.
  • Page units are millimetres throughout unless you change them.

Create the layout and the map item

from qgis.core import (QgsProject, QgsPrintLayout, QgsLayoutItemMap,
                       QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes)

project = QgsProject.instance()
layout = QgsPrintLayout(project)
layout.initializeDefaults()                 # one A4 landscape page
layout.setName("Ward map")
project.layoutManager().addLayout(layout)

map_item = QgsLayoutItemMap(layout)
map_item.setId("main map")
map_item.attemptMove(QgsLayoutPoint(15, 20, QgsUnitTypes.LayoutMillimeters))
map_item.attemptResize(QgsLayoutSize(180, 120, QgsUnitTypes.LayoutMillimeters))
layout.addLayoutItem(map_item)

Breakdown: initializeDefaults() gives the layout a page so items have somewhere to go — without it, everything is added to a layout with no pages and nothing renders. attemptMove() and attemptResize() are named that way because a locked or constrained item may refuse; they take a point and a size carrying their own units, so mixing millimetres and pixels is impossible by construction. Setting an id matters more than it looks: it is how you find the item again in a later script, and how a legend or scale bar is told which map it belongs to. The item must be added to the layout after configuration, or the layout does not own it and it disappears.

Set an extent, or set a scale

from qgis.core import QgsRectangle

wards = project.mapLayersByName("Wards")[0]

map_item.zoomToExtent(wards.extent())          # fit the layer

map_item.setExtent(QgsRectangle(432000, 186000, 435000, 188000))

map_item.setScale(10000)                       # 1:10 000, centre unchanged

Breakdown: These three are alternatives, not a sequence. zoomToExtent() is the usual starting point and adds a small margin. setExtent() takes coordinates in the map's coordinate system, which is the project CRS unless the item overrides it — passing latitude and longitude to a map in a national grid produces an empty map centred on the origin, which is the most common version of this mistake. setScale() keeps the centre and changes the zoom, and is what you want whenever the printed scale is a requirement rather than an outcome: a plan that must be at 1:1250 is set by scale, and the extent follows.

The aspect ratio rule matters for both: QGIS guarantees the requested extent is visible, so a wide extent in a tall frame is padded vertically. If pages must show exactly the same ground area, size the frame to the data's proportions or set a scale instead.

Lock the layers so the map cannot drift

By default a map item draws whatever the project currently shows, which means a colleague toggling a layer changes every layout in the project.

map_item.setFollowVisibilityPreset(False)
map_item.setKeepLayerSet(True)
map_item.setLayers([wards, roads, buildings])   # drawn top to bottom
map_item.setKeepLayerStyles(True)

Breakdown: setKeepLayerSet(True) with an explicit list freezes what this map draws, in the order given — first in the list is drawn on top, matching the layer tree convention. setKeepLayerStyles(True) goes further and freezes the styling as it is now, so a later change to the project's symbology leaves the layout looking as it was designed; that is right for an archived map and wrong for a template meant to reflect the current styling. The alternative is a map theme: setFollowVisibilityPreset(True) with setFollowVisibilityPresetName("Print") ties the map to a named theme, which is the cleanest arrangement when several layouts need different layer sets from one project.

What a toggled layer does to your layoutA map item that follows the project draws whatever is currently checked, so a colleague switching off a layer silently changes every export from that layout. A map item with a locked layer set keeps drawing the layers it was given, regardless of what the layer tree shows, which is what a finished layout needs.A layout that follows the project is a layout somebody can breakfollows the layer treedesigned with 3 layers checkeda colleague unchecks buildingstonight's 200 pages have no buildingsand nothing reported an errorlocked layer setsetKeepLayerSet with an explicit lista colleague unchecks buildingsthe layout is unaffectedit draws what it was given

Add an overview frame

An overview map showing where the main map sits is two items and one link.

overview_map = QgsLayoutItemMap(layout)
overview_map.setId("overview")
overview_map.attemptMove(QgsLayoutPoint(210, 20, QgsUnitTypes.LayoutMillimeters))
overview_map.attemptResize(QgsLayoutSize(60, 45, QgsUnitTypes.LayoutMillimeters))
overview_map.zoomToExtent(wards.extent())
layout.addLayoutItem(overview_map)

overview = overview_map.overview()
overview.setLinkedMap(map_item)
overview.setEnabled(True)

Breakdown: The overview is a property of the overview map, pointing at the map it should draw a frame for — the direction catches people out, since it reads more naturally the other way. setLinkedMap() makes the frame follow the main map automatically, including through every page of an atlas, so a ward series gets a correct locator map for free. The frame's symbol can be styled like any other fill symbol, and a semi-transparent fill with a strong outline reads better at small sizes than an outline alone. Multiple overviews are supported through overviews().addOverview() when a layout needs both a regional and a national locator.

How the locator frame is producedThe overview map covers a wider area at a smaller scale. Its overview property points at the main map, so it draws a rectangle showing where the main map's extent falls. Because the link is to the map item rather than to a fixed rectangle, the frame follows every page of an atlas without further configuration.The overview points at the map, not at a rectanglemain map itemthe extent for this pageoverview map itemthis pagesetLinkedMapso every atlas page gets a correct locator with no extra work

Find and reuse an existing map item

Scripts that update a template rather than building one need to find the item:

map_item = layout.itemById("main map")
if map_item is None:
    raise RuntimeError("layout has no item with id 'main map'")

map_item.setScale(5000)
map_item.refresh()
layout.refresh()

Breakdown: itemById() is why setting an id when the layout is built matters — the alternative is iterating layout.items() and matching on type, which breaks the moment a second map is added. Refreshing the item and then the layout applies the change and updates anything that depends on it, such as a scale bar or a linked overview; skipping the refresh leaves a scale bar reporting the old scale in the exported PDF, which is a genuinely embarrassing failure. This is the shape most production scripts take: a cartographer builds the template, the script finds items by id and changes only what varies, following the template pattern in Working with QGIS Projects in PyQGIS.

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9QgsLayoutItemMap with all methods shown.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical; newer releases add map item elevation and 3D options that do not affect these calls.

QGIS 2's QgsComposerMap is gone entirely. Any example using QgsComposition predates 3.0 and needs rewriting against the layout API.

Troubleshooting

  • The map is empty. The extent is in the wrong coordinate system, or the layer set is locked to layers that are not valid.
  • Nothing appears on the page. initializeDefaults() was not called, so the layout has no page, or the item was never added.
  • The extent shows more than requested. Expected — the frame's aspect ratio wins. Set a scale, or resize the frame.
  • The scale bar disagrees with the map. The item was changed without a refresh, or the scale bar is linked to a different map.
  • A layer vanished from an old layout. It follows the project and somebody unchecked it. Lock the layer set.
  • itemById() returns None. No id was set when the layout was built. Set ids on every item a script will touch.

Conclusion

Create the map item, place and size it in millimetres with attemptMove() and attemptResize(), and give it an id. Choose deliberately between an extent and a scale — the frame's proportions win over a requested extent, so set a scale when the printed scale matters. Lock the layer set for anything finished, link an overview map to the main one for a locator that follows an atlas, and refresh both item and layout after any change so dependent items stay correct.

Frequently Asked Questions

Why does my map show more area than the extent I set? The frame's aspect ratio differs from the extent's. QGIS expands the extent so all of it is visible rather than cropping.

How do I set the map to a specific CRS?map_item.setCrs(QgsCoordinateReferenceSystem("EPSG:27700")). Without it the map uses the project CRS, and the extent you pass must match whichever applies.

Can the map rotate? Yes — setMapRotation() rotates the map content within the frame, which is how north-up-on-the-route sheets are made.

How do I stop labels being cut off at the frame edge? Set a larger extent, or enable label margins on the map item so labels near the edge are drawn inside it.

Does the map item redraw automatically when the data changes? In the interface yes; in a script call refresh() explicitly before exporting, or you may export the previous state.

How do I add a legend for this map? Create a QgsLayoutItemLegend and call setLinkedMap() on it with this item — see Add a Legend to a Layout in PyQGIS.