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.

What happens when the extent's CRS is not the canvas CRSA layer extent in metres reads roughly five hundred thousand by five point six million. Passed directly to a canvas whose project CRS is degrees, those numbers fall far outside the valid latitude and longitude range, so the canvas jumps to an empty area. Transforming the rectangle first produces coordinates inside the valid range and the canvas lands on the data.The rectangle is just four numbers — they mean nothing without a CRSlayer.extent()500 000 … 510 0005 600 000 … 5 610 000EPSG:32633, metrespassed straight throughcanvas expects degreesvalid range is ±180 / ±90transformed first12.42 … 12.5550.53 … 50.62canvas goes nowhereblank view, no errorcanvas lands on the dataas intended

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer, running as a desktop application.
  • A layer loaded in the project. iface is 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.

How much margin each kind of zoom wantsThree panels show the same subject at increasing margins. At factor one the geometry touches the frame and its symbols are clipped. At factor one point one there is a comfortable border, which suits zooming to a whole layer. At factor one point three the subject sits smaller with surrounding context visible, which suits zooming to a single feature.Match the margin to what the view is forscale(1.0)symbols clipped at the framescale(1.1) — a layera clean border all roundscale(1.3) — a featuresurrounding context visible

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.

Two extents that need a guardA normal extent is a rectangle with real width and height and needs no special handling. An empty layer produces a null extent whose coordinates are inverted, and zooming to it leaves the canvas somewhere arbitrary. A single point produces an extent of zero width and height, and zooming to it puts the canvas at infinite magnification, so it must be grown by a fixed distance first.Check isEmpty() and isNull() before trusting an extentnormal extentwidth and height > 0zoom directlyempty layerno featuresextent.isNull() is Trueskip, do not zoomsingle pointwidth and height are 0grow by a fixed distance

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 versionPythonNotes
3.28 LTR3.9Identical API. transformBoundingBox() available throughout 3.x.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsMapCanvas.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 of transformBoundingBox(). 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 than scale().
  • 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. Call setActiveLayer() 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.