Zoom the Canvas to a Layer Extent in PyQGIS
Zooming to a layer is two lines when the layer and the project happen to share a coordinate reference system, and a puzzle when they do not — the canvas jumps somewhere in the North Atlantic, or to coordinates in the millions, and nothing about the code looks wrong. layer.extent() returns a rectangle in the layer's CRS, and the canvas expects one in the project's. Everything else on this page follows from that single fact.
This page is a focused recipe within Map Canvas Control and Image Export in PyQGIS. It covers zooming to a layer, transforming the extent when the CRSs differ, padding so features are not clipped at the frame, and the degenerate cases — an empty layer and a single point — that produce nonsense without a guard.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer, running as a desktop application.
- A layer loaded in the project.
ifaceis available in the Python Console and in a plugin. - Some awareness of the project CRS versus layer CRS distinction — see Coordinate Reference Systems in PyQGIS.
The simple case
When layer and project share a CRS, iface provides a ready-made helper that does everything including the refresh.
from qgis.core import QgsProject
layer = QgsProject.instance().mapLayersByName("parcels")[0]
iface.setActiveLayer(layer)
iface.zoomToActiveLayer()
Breakdown: zoomToActiveLayer() is the scripted equivalent of the "Zoom to Layer" context-menu item, and it handles the CRS transform internally — which is exactly why it is worth preferring over hand-rolling the extent when the layer is already in the project. It also applies a small margin automatically. The only awkwardness is that it works on the active layer, so setActiveLayer() has to run first, changing the user's selection in the layer panel as a side effect.
Transform the extent yourself
When you need control — a specific margin, a computed extent, or a layer that should not become active — do it explicitly.
from qgis.core import (
QgsCoordinateTransform,
QgsProject,
)
canvas = iface.mapCanvas()
layer = QgsProject.instance().mapLayersByName("parcels")[0]
project = QgsProject.instance()
extent = layer.extent()
if layer.crs() != project.crs():
transform = QgsCoordinateTransform(layer.crs(), project.crs(), project)
extent = transform.transformBoundingBox(extent)
extent.scale(1.1)
canvas.setExtent(extent)
canvas.refresh()
Breakdown: transformBoundingBox() is the correct method here, not transform(). A rectangle's corners do not map to a rectangle under a projection — the edges curve — so transforming only the two corner points can produce a box that excludes part of the data. transformBoundingBox() samples along the edges and returns a rectangle guaranteed to contain the transformed shape. extent.scale(1.1) adds a ten per cent margin in place, which stops symbols and labels being clipped at the frame edge.
Zoom to a feature or a selection
The same pattern narrows to one feature or to whatever the user has selected.
from qgis.core import QgsCoordinateTransform, QgsProject
canvas = iface.mapCanvas()
layer = QgsProject.instance().mapLayersByName("parcels")[0]
project = QgsProject.instance()
def zoom_to(geometry, source_crs, margin=1.3):
extent = geometry.boundingBox()
if source_crs != project.crs():
transform = QgsCoordinateTransform(source_crs, project.crs(), project)
extent = transform.transformBoundingBox(extent)
extent.scale(margin)
canvas.setExtent(extent)
canvas.refresh()
feature = next(layer.getFeatures())
zoom_to(feature.geometry(), layer.crs())
if layer.selectedFeatureCount():
zoom_to(layer.boundingBoxOfSelected(), layer.crs())
Breakdown: Factoring the transform and padding into one helper means the CRS handling is written once and cannot be forgotten in the second call. A larger margin — 1.3 rather than 1.1 — suits a single feature, which otherwise fills the frame and reads as a diagram rather than a map. boundingBoxOfSelected() returns the combined extent of the selection in the layer's CRS, and it returns an empty rectangle when nothing is selected, which is why the count check comes first.
Choosing the margin is a judgement about what the view is for, and the three common cases want visibly different amounts of context.
A single feature zoomed to a tight fit reads as a diagram rather than a location, which is why the helper above takes the margin as a parameter rather than hard-coding one.
Guard the degenerate cases
Two extents break a naive zoom: an empty layer and a layer whose extent has zero width or height.
def safe_zoom(canvas, extent, minimum_span=250.0, margin=1.1):
"""Zoom to an extent, handling empty layers and zero-size extents."""
if extent.isNull() or extent.isEmpty():
print("nothing to zoom to")
return False
if extent.width() == 0 or extent.height() == 0:
extent.grow(minimum_span / 2)
else:
extent.scale(margin)
canvas.setExtent(extent)
canvas.refresh()
return True
Breakdown: isNull() catches an extent that was never set — a layer with no features returns one whose minimum coordinates exceed its maximum. width() == 0 catches the single-point case, where scale() would multiply zero by 1.1 and leave it at zero, sending the canvas to infinite magnification. grow() expands by an absolute distance in map units rather than a factor, which is the only thing that works on a degenerate extent. Returning a bool lets the caller distinguish "zoomed" from "nothing to show" without inspecting the canvas.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical API. transformBoundingBox() available throughout 3.x. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | QgsMapCanvas.zoomToFeatureExtent() handles the transform and padding in one call. |
setExtent(), QgsCoordinateTransform and QgsRectangle's helpers are unchanged across 3.x.
Troubleshooting
- The canvas jumps somewhere blank. The extent was in the layer's CRS and the project uses a different one. Transform it with
transformBoundingBox(). - Part of the layer is cut off after a transform.
transform()was used on the rectangle instead oftransformBoundingBox(). Corner-only transformation does not account for curved edges. - The canvas zooms to infinity. The extent has zero width or height — a single point, or a layer with one vertical line. Use
grow()rather thanscale(). - Nothing happens.
refresh()was not called, or the extent was identical to the current one. The canvas skips a repaint when nothing changed. - Features touch the frame edge. No margin was applied.
extent.scale(1.1)is the habit worth forming. zoomToActiveLayer()zooms to the wrong layer. The active layer is whatever is highlighted in the layer panel. CallsetActiveLayer()first, or transform the extent yourself.
Conclusion
Every zoom problem in PyQGIS reduces to the CRS of the rectangle. Use iface.zoomToActiveLayer() when the convenience is worth changing the active layer; otherwise transform the extent with transformBoundingBox(), pad it with scale(), and guard the empty and zero-size cases before handing it to the canvas.
Frequently Asked Questions
Why does zooming to my layer show a blank canvas?layer.extent() is in the layer's CRS, and the canvas expects the project's. Transform the rectangle with QgsCoordinateTransform(...).transformBoundingBox() before calling setExtent().
Why use transformBoundingBox instead of transform?
A rectangle's edges curve under a projection, so transforming only the corners can produce a box that excludes part of the data. transformBoundingBox() samples along the edges and returns a rectangle guaranteed to contain everything.
How do I zoom to the selected features?
Use layer.boundingBoxOfSelected(), which returns the combined extent in the layer's CRS. Check selectedFeatureCount() first — with nothing selected it returns an empty rectangle.
How do I zoom to a single point without going to infinite magnification?
A point's extent has zero width and height, so multiplying it by a margin factor changes nothing. Call extent.grow(distance) to expand it by an absolute number of map units instead.
Does setExtent guarantee the canvas shows exactly that rectangle? Not quite. The canvas has its own aspect ratio, and when the rectangle you supply does not match it QGIS expands the extent on the shorter axis so nothing is cut off. The result therefore contains your rectangle but is usually a little larger. If an exact framing matters — for a map series where every page must cover the same ground — compute the rectangle from the canvas's own width-to-height ratio before setting it.
Why does zooming feel different between two projects with the same data?
The project CRS differs, so the same ground area occupies a different coordinate range and the scale denominator reported by canvas.scale() is computed against different map units. Zooming by scale rather than by extent makes the behaviour consistent, because a scale denominator means the same thing in any projected CRS whose units are metres.
How do I zoom out to show everything in the project?
Use canvas.zoomToFullExtent(), which combines the extents of every visible layer and handles the CRS transforms internally — considerably simpler than unioning the rectangles yourself.
Does the canvas keep a zoom history?
Yes. canvas.zoomToPreviousExtent() and zoomToNextExtent() walk the stack QGIS maintains, which is what the back and forward buttons on the Map Navigation toolbar use.
Does zooming trigger a data reload? Only for providers that fetch by extent, such as WMS or a remote WFS. A local GeoPackage is read from the same file whatever the view, so zooming costs a render rather than a fetch.