Map Canvas Control and Image Export in PyQGIS

Turning a styled project into a picture is the last step of most cartographic automation, and it is the step where scripts most often diverge from what the author saw on screen: the wrong extent, an unexpected scale, a transparent background, or labels that were never drawn because the render finished before they were placed. All of those come from the same root cause — confusing the canvas, which is a widget showing a view, with the map settings, which are the recipe a renderer follows.

This guide sits inside PyQGIS Cartography & Data Visualization. It covers what the canvas actually is, how to control the visible extent and scale, how to export exactly what is on screen, and — the part that matters most for automation — how to render a map to an image with no canvas and no GUI at all, which is what any scheduled or server-side job needs.

Canvas and headless export share one pipelineLayers with their renderers feed into QgsMapSettings, which holds the extent, output size, DPI, CRS and background colour. A render job consumes those settings and produces pixels. Those pixels are either painted onto the on-screen QgsMapCanvas widget or written directly to a PNG file, with no GUI involved.The canvas is one consumer of the pipeline, not the pipeline itselflayers+ renderers+ label settingsQgsMapSettingsextent · output sizeDPI · destination CRSbackground · flagsthe whole reciperender jobdraws to a QImageparallel or sequentialcanvason screenneeds a GUIPNG fileheadlessno GUI needed

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A project with at least one styled layer. If styling is what you are automating, start with Programmatic Layer Styling.
  • For the canvas sections, QGIS Desktop with iface available — the Python Console or a plugin.
  • For the headless sections, a working standalone environment; see Running Python Scripts Outside QGIS Desktop.

The canvas is a view, not the map

QgsMapCanvas is a Qt widget. It owns a QgsMapSettings describing what to draw, and it repaints itself when that changes. Almost everything you might want to "do to the map" is really a change to those settings followed by a refresh.

canvas = iface.mapCanvas()

print(canvas.extent().toString(2))          # current visible rectangle
print(canvas.scale())                        # denominator, e.g. 25000.0
print(canvas.mapUnitsPerPixel())
print([layer.name() for layer in canvas.layers()])
print(canvas.mapSettings().destinationCrs().authid())

Breakdown: extent() returns a QgsRectangle in the canvas CRS — toString(2) formats it to two decimals for logging. scale() returns the denominator, so 25000.0 means 1:25 000. layers() returns only the visible layers in draw order, which is often different from QgsProject.instance().mapLayers() and is the usual explanation for a layer that "isn't in the export".

The canvas draws asynchronously. A script that changes the extent and immediately grabs an image will capture the previous frame, because the render has not finished. This is the single most common bug in canvas export code, and the fix is to either wait for the renderComplete signal or bypass the canvas entirely with a render job — covered below.

Controlling the visible extent

Three methods cover almost every need, and choosing the right one avoids a lot of arithmetic.

from qgis.core import QgsProject, QgsRectangle

canvas = iface.mapCanvas()
layer = QgsProject.instance().mapLayersByName("parcels")[0]

canvas.setExtent(layer.extent())            # fit the whole layer
canvas.zoomScale(10000)                      # jump to 1:10 000, keeping the centre
canvas.setExtent(QgsRectangle(500000, 5600000, 510000, 5610000))
canvas.refresh()

Breakdown: layer.extent() is in the layer's CRS. If the project CRS differs, the rectangle must be transformed first or the canvas jumps somewhere unexpected — the recipe in Zoom the Canvas to a Layer Extent handles that. zoomScale() sets the scale denominator directly and is what you want for consistent output across a map series. refresh() schedules the repaint; without it the canvas may keep showing the old frame until something else triggers a redraw.

Fitting an extent, padding it, and pinning a scaleThree panels show the same cluster of parcel polygons. In the first the extent hugs the data with features touching the frame edge. In the second the extent is scaled by 1.1 leaving a visible margin. In the third the extent has been widened to satisfy a fixed 1:10000 scale, so the data sits centred with generous space around it.A raw layer extent touches the frame — pad it before exportinglayer.extent()features clipped at the edgeextent.scaled(1.1)a clean margin all roundzoomScale(10000)identical scale across a series

Padding an extent is a one-liner that is worth making a habit, because a rectangle that exactly bounds the data always clips symbols and labels at the frame:

extent = layer.extent()
extent.scale(1.1)          # 10% margin, in place
canvas.setExtent(extent)

Breakdown: QgsRectangle.scale() mutates the rectangle around its centre, so grab a copy first if the original is still needed. A factor of 1.05–1.15 covers most symbol overhang; for point layers with large markers, more.

Exporting what is on screen

When you genuinely want the on-screen view — a plugin's "save this view" button — canvas.saveAsImage() is the direct route, and it writes a world file alongside the image so the result stays georeferenced.

canvas = iface.mapCanvas()
canvas.saveAsImage("/data/exports/current_view.png")

Breakdown: The image is exactly the canvas widget's pixel size, which means the output resolution depends on the user's window. That is fine for a screenshot and unacceptable for a deliverable, which is the reason the next section exists. A .pgw world file is written next to the PNG automatically.

Rendering headless with a render job

For anything reproducible — a scheduled export, a map per feature, a server-side thumbnail — build QgsMapSettings yourself and hand it to a render job. Nothing here touches the GUI, so it runs under cron or in a container.

The headless render sequenceFive numbered steps run left to right. First configure QgsMapSettings with layers, extent, size, DPI and background. Second construct a QgsMapRendererParallelJob from those settings. Third call start. Fourth call waitForFinished, which blocks until every layer has drawn. Fifth call renderedImage and save it. A note warns that skipping the wait step produces a blank or partial image.Five steps, and step four is the one everyone forgets1QgsMapSettingslayers, extent, size2ParallelJob(...)build the job3start()returns immediately4waitForFinished()blocks until drawn5renderedImage()save itskip step 4 and renderedImage() returns a blank or half-drawn frame

from qgis.core import QgsMapSettings, QgsMapRendererParallelJob, QgsProject
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor

layer = QgsProject.instance().mapLayersByName("parcels")[0]

settings = QgsMapSettings()
settings.setLayers([layer])
settings.setBackgroundColor(QColor("#ffffff"))
settings.setOutputSize(QSize(1600, 1200))
settings.setOutputDpi(300)
settings.setDestinationCrs(layer.crs())

extent = layer.extent()
extent.scale(1.1)
settings.setExtent(extent)

job = QgsMapRendererParallelJob(settings)
job.start()
job.waitForFinished()
job.renderedImage().save("/data/exports/parcels.png", "png")

Breakdown: setLayers() takes the layers in draw order, with the first item drawn on top — the reverse of what many people assume, and worth checking when a basemap covers everything. setBackgroundColor() avoids the transparent background that catches people out when the image lands in a document. setOutputDpi() does not resize the image; it tells the renderer how to interpret millimetre-based symbol and font sizes, so raising it makes symbols render proportionally rather than shrinking them. waitForFinished() is mandatory in a script — start() returns immediately and renderedImage() before completion gives you a partial frame. Render a Layer to an Image Headless in PyQGIS shows the full standalone script including application startup.

Getting the aspect ratio right

If the output size and the extent disagree about aspect ratio, QGIS expands the extent to fit and your carefully chosen bounds silently change. Derive one from the other instead:

extent = layer.extent()
extent.scale(1.1)
width = 1600
height = int(width * extent.height() / extent.width())
settings.setOutputSize(QSize(width, height))
settings.setExtent(extent)

Breakdown: Fixing the width and computing the height from the extent's own proportions guarantees no expansion happens. For a fixed-size output — a thumbnail grid, say — invert it: fix the size and expand the extent yourself so you know exactly what was added.

Scale, DPI and symbol size

The relationship between these three trips up nearly everyone the first time. Symbol and label sizes in QGIS are specified in millimetres or points, both of which are physical units. Converting them to pixels requires a DPI. So the same map rendered at 96 DPI and 300 DPI with the same pixel dimensions shows symbols at very different apparent sizes relative to the map content.

The practical rules are short. To make an image bigger at the same visual design, raise the pixel size and the DPI together. To make symbols look smaller relative to the data, raise pixel size alone. And to guarantee a specific map scale in the output, compute the extent from the scale rather than the other way round:

def extent_for_scale(centre, scale, size_px, dpi):
    """Rectangle that renders at exactly 1:scale for the given output size."""
    from qgis.core import QgsRectangle
    inches_wide = size_px.width() / dpi
    metres_wide = inches_wide * 0.0254 * scale
    metres_high = (size_px.height() / dpi) * 0.0254 * scale
    return QgsRectangle(
        centre.x() - metres_wide / 2, centre.y() - metres_high / 2,
        centre.x() + metres_wide / 2, centre.y() + metres_high / 2,
    )

Breakdown: The chain is pixels → inches (divide by DPI) → metres on paper (× 0.0254) → metres on the ground (× the scale denominator). It only holds in a projected CRS whose units are metres, which is another reason to reproject before rendering — see Coordinate Reference Systems in PyQGIS. This helper is the core of any consistent map series, and pairs naturally with the atlas workflow in Automating Atlas Map Series.

Background, antialiasing and other render flags

QgsMapSettings carries a flag set that controls quality and behaviour. The two worth setting explicitly in export code are antialiasing, which is on by default on the canvas but easy to lose in a hand-built settings object, and label drawing.

from qgis.core import Qgis, QgsMapSettings

settings.setFlag(Qgis.MapSettingsFlag.Antialiasing, True)
settings.setFlag(Qgis.MapSettingsFlag.DrawLabeling, True)
settings.setFlag(Qgis.MapSettingsFlag.UseAdvancedEffects, True)

Breakdown: DrawLabeling is the answer to "why are my labels missing from the export" — a fresh QgsMapSettings does not always inherit the canvas's flags. UseAdvancedEffects enables blend modes and layer effects, which are silently dropped without it, so a map that uses a multiply blend for hillshading will look flat. On QGIS 3.28 and earlier these constants live on QgsMapSettings rather than Qgis; both spellings work on 3.34.

For the background, prefer an explicit colour over relying on the default. QColor(Qt.transparent) gives a transparent PNG when you want to composite the map into something else — see Set the Map Canvas Background Colour in PyQGIS.

Rendering only what the map needs

A render draws every feature of every layer you hand it, whether or not that feature is inside the extent or visible at the current scale. On a large project that is the difference between a two-second export and a two-minute one, and the fix is to be explicit about the subset.

from qgis.core import QgsMapSettings, QgsProject

project = QgsProject.instance()
settings = QgsMapSettings()

# Only the layers this map is about, in explicit draw order (first = on top).
wanted = ["parcels", "roads", "basemap"]
settings.setLayers([project.mapLayersByName(name)[0] for name in wanted])

# Honour the scale ranges configured on each layer.
settings.setFlag(Qgis.MapSettingsFlag.UseRenderingOptimization, True)

Breakdown: Building the layer list by name from the project rather than copying canvas.layers() makes the export independent of whatever the user has ticked in the layer panel — a script that produces different output depending on GUI state is not reproducible. UseRenderingOptimization lets QGIS apply per-layer simplification and skip geometry that cannot affect the visible pixels; it is on by default for the canvas and easy to lose in a hand-built settings object.

Scale-dependent visibility is honoured automatically, which is worth knowing in both directions: a layer with a minimum scale of 1:5 000 will silently not appear in a 1:50 000 export, and that is usually correct but occasionally baffling. Check with layer.hasScaleBasedVisibility() and layer.minimumScale() when a layer you passed explicitly refuses to draw.

For very large vector layers, a provider-side filter is more effective than any render flag, because it prevents the features being read at all:

parcels = project.mapLayersByName("parcels")[0]
parcels.setSubsetString('"status" = \'active\'')
# ... render ...
parcels.setSubsetString("")        # always restore it

Breakdown: setSubsetString() applies a provider-level filter that behaves like a permanent WHERE clause — it affects rendering, iteration, and the attribute table alike. That power is also its danger: forgetting to clear it leaves the layer filtered for the rest of the session and quietly changes every later result. Wrapping the render in a try/finally that restores the previous subset is the safe form.

Exporting one image per feature

The pattern that pays for learning all of this is the per-feature export: a thumbnail for every parcel, a locator map for every site, a preview for every row in a report. Because the settings object is cheap to reuse, the loop is short.

from pathlib import Path
from qgis.core import QgsMapSettings, QgsMapRendererParallelJob, QgsProject
from qgis.PyQt.QtCore import QSize
from qgis.PyQt.QtGui import QColor

layer = QgsProject.instance().mapLayersByName("parcels")[0]
context = QgsProject.instance().mapLayersByName("basemap")[0]
out_dir = Path("/data/exports/thumbnails")
out_dir.mkdir(parents=True, exist_ok=True)

settings = QgsMapSettings()
settings.setLayers([layer, context])          # parcels drawn over the basemap
settings.setBackgroundColor(QColor("#ffffff"))
settings.setOutputSize(QSize(640, 480))
settings.setOutputDpi(96)
settings.setDestinationCrs(layer.crs())

for feature in layer.getFeatures():
    extent = feature.geometry().boundingBox()
    if extent.isEmpty():
        continue
    extent.scale(1.6)                          # generous context around the subject
    settings.setExtent(extent)

    job = QgsMapRendererParallelJob(settings)
    job.start()
    job.waitForFinished()
    job.renderedImage().save(str(out_dir / f"{feature['parcel_id']}.png"), "png")

Breakdown: The settings object is configured once and only its extent changes per iteration, which avoids re-resolving layers and styles on every pass. boundingBox() on the geometry gives the feature's own extent — checking isEmpty() skips features with no geometry, which would otherwise produce an image of nothing. A scale factor of 1.6 is deliberately larger than the 1.1 used for a full-layer export, because a single feature filling its frame reads as a diagram rather than a map. A fresh job per feature is correct: jobs are single-use, and reusing one silently returns the first render.

For a hundred features this runs in seconds; for a hundred thousand, batch it and write to a fast local disk rather than a network share. When the output needs a title, legend and scale bar rather than a bare map, the layout system is the better tool — see Automated Map Layout Generation and Exporting Multiple QGIS Layouts to PDF.

Troubleshooting canvas and export problems

Most export failures fall into a handful of recognisable shapes, and the symptom usually names the cause.

  • The image is empty but the file is the right size. Either waitForFinished() was skipped, or setLayers() was given an empty list. Print len(settings.layers()) before starting the job.
  • Only some layers appear. canvas.layers() returns visible layers only, so a copied list inherits whatever was unticked in the layer panel. Build the list from QgsProject.instance().mapLayersByName() when you want a specific set regardless of the tree state.
  • The extent is not what you asked for. The output size and extent disagreed on aspect ratio and QGIS expanded the extent to fit. Derive one from the other as shown above.
  • Symbols are tiny or enormous. The DPI does not match the one the style was designed at. Rendering a 96 DPI design at 300 DPI without changing the pixel size shrinks everything by about a third relative to the frame.
  • The export is misaligned with other data. setDestinationCrs() was never called, so the render used whatever the settings object defaulted to rather than the CRS the extent was expressed in.
  • The script hangs in a standalone run. QgsMapRendererParallelJob needs a running Qt event loop for its worker threads. In a headless script, either call QgsApplication.processEvents() in a wait loop or use QgsMapRendererSequentialJob, which does not.

Key takeaways

  • The canvas is a widget; QgsMapSettings is the recipe. Anything reproducible should build settings directly rather than driving the canvas.
  • The canvas renders asynchronously. Grabbing an image straight after changing the extent captures the old frame; use a render job instead.
  • setLayers() order is top-first. The first layer in the list draws on top of the rest.
  • waitForFinished() is not optional in a script. Without it renderedImage() returns a blank or partially drawn frame.
  • Pad your extents. A rectangle that exactly bounds the data clips symbols and labels at the frame edge; extent.scale(1.1) is the habit to build.
  • DPI scales symbols, not the image. Raise pixel size and DPI together to enlarge a map without changing its design.
  • Set DrawLabeling and UseAdvancedEffects explicitly when building settings by hand, or labels and blend modes vanish from the output.

Frequently Asked Questions

Why is my exported image blank or only partly drawn?job.start() returns immediately and rendering continues in the background. Call job.waitForFinished() before renderedImage(). The same asynchrony explains a canvas screenshot that shows the previous extent.

Why are labels missing from a headless export? A hand-built QgsMapSettings does not necessarily have the DrawLabeling flag set. Enable it explicitly with settings.setFlag(Qgis.MapSettingsFlag.DrawLabeling, True).

How do I export at a specific map scale? Compute the extent from the scale rather than setting a scale on the output. Convert output pixels to inches with the DPI, then to ground metres by multiplying by 0.0254 and the scale denominator, and build a rectangle of that size around your centre point.

Does raising the DPI make the image larger? No. DPI only tells the renderer how to convert millimetre-based symbol and font sizes into pixels. To produce a larger image you must also raise setOutputSize(); raising both together keeps the design proportions identical.

Why does my exported map have a transparent background? Because no background colour was set. QgsMapSettings defaults to transparent when constructed directly. Call setBackgroundColor(QColor("#ffffff")) — or any colour — before rendering.