Build a Heatmap Renderer in PyQGIS

A heatmap turns a scatter of points into a continuous surface of density, and QGIS offers two quite different ways to make one. QgsHeatmapRenderer is a style: it draws the density live on the canvas, follows the current extent, and disappears the moment you switch the renderer. The Heatmap processing algorithm is an analysis: it produces a raster you can classify, clip, sample and hand to somebody else. Choosing the wrong one leads to hours spent trying to get numbers out of something that was only ever a picture.

This recipe belongs to Symbol Layers & Advanced Symbology in PyQGIS. It covers configuring the renderer from Python, weighting points by an attribute, controlling the radius in ground units so the map means the same thing at every zoom, and when to switch to the raster route instead.

From points to a density surfaceEach point contributes a circular kernel whose influence falls off with distance out to the chosen radius. Where kernels overlap their values add. The resulting continuous surface is mapped through a colour ramp, with the maximum value either detected automatically from the visible extent or fixed by the author.Radius sets the kernel; the ramp only recolours the result1 · the pointsa weight field, optionally2 · kernels sumoverlap adds, radius decides how much3 · ramp appliedlow density to high densitywith an auto maximum, the same data recolours itself as you pan

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A point layer with enough points to have density worth showing; the examples use one called incidents.
  • A projected CRS if the radius should mean metres. In EPSG:4326 a radius in map units is degrees, and the heatmap will be visibly stretched north–south.

Configure the renderer

QgsHeatmapRenderer has a small surface: radius, weight, colour ramp, maximum value and render quality.

from qgis.core import (
    QgsProject, QgsHeatmapRenderer, QgsStyle, QgsUnitTypes,
)

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

renderer = QgsHeatmapRenderer()
renderer.setRadius(500)
renderer.setRadiusUnit(QgsUnitTypes.RenderMapUnits)
renderer.setColorRamp(QgsStyle.defaultStyle().colorRamp("Magma"))
renderer.setRenderQuality(3)

layer.setRenderer(renderer)
layer.triggerRepaint()

Breakdown: setRadiusUnit(RenderMapUnits) is the single most important line. In the default millimetre mode the radius is a paper distance, so the heatmap tells a different story at every zoom level and cannot be compared between two exports at different scales. In map units it is a ground distance — five hundred metres here — and the map means the same thing everywhere. setRenderQuality() is a downsampling factor where 1 is exact and larger numbers are faster and blockier; 3 is a good interactive setting, and 1 is right for the final export.

QgsStyle.defaultStyle().colorRamp(name) pulls a ramp from the user's style database. The name must match exactly, and it returns None silently when it does not — so check it before assigning, or a typo leaves you with a black rectangle:

ramp = QgsStyle.defaultStyle().colorRamp("Magma")
if ramp is None:
    raise RuntimeError("colour ramp not found in the style database")
renderer.setColorRamp(ramp)

Breakdown: Listing QgsStyle.defaultStyle().colorRampNames() in the console is the quick way to find the exact spelling; the set differs slightly between QGIS versions and between users who have imported extra styles.

Weight the points

By default every point counts as one. A weight expression makes some points count for more, which is what turns "where are the shops" into "where is the floor space".

renderer.setWeightExpression("coalesce(\"severity\", 1)")
layer.triggerRepaint()

Breakdown: Any QGIS expression is accepted, so this can be a plain field name, arithmetic, or a case statement. coalesce() matters more here than almost anywhere else: a null weight contributes nothing, so a field with gaps quietly produces a heatmap of "records where severity was filled in" rather than of the phenomenon. Where a weight is genuinely unknown, decide whether the right answer is 1 or 0 and say so in the expression instead of letting the null decide.

Fix the maximum, or the map lies

Left alone, the renderer finds the maximum density in the current view and stretches the ramp to it. Pan somewhere quiet and the quiet area lights up as though it were busy.

renderer.setMaximumValue(40.0)

Breakdown: Setting an explicit maximum pins the top of the ramp to a fixed density, so colours are comparable across extents, across exports and between two maps of different periods. Zero — the default — means automatic. Choosing the number is the work: run the layer once with automatic scaling, note the value QGIS reports in the renderer's properties at the extent you care about, round it to something defensible, and write that into the script with a comment explaining the choice. Any map that will be compared with another map needs this line.

Why an automatic maximum is not comparableWith an automatic maximum, a sparse extent stretches its colour ramp to its own local peak, so a low density is painted the same colour as a high density elsewhere. With a fixed maximum both extents use the same scale and the sparse area correctly appears cool.Automatic scaling makes every extent look like a hotspotmaximum = 0 (automatic)busy areapeak 38quiet areapeak 4identical colour, ninefold differencemaximum = 40 (fixed)busy areapeak 38quiet areapeak 4the difference survives the map

Choosing the radius

The radius is the only parameter that changes what the map claims, and it is chosen far too often by dragging a slider until the picture looks nice.

A radius is a statement about the distance over which one event influences its surroundings. For street crime, a couple of hundred metres is a walkable block and defensible. For retail catchment, a kilometre or two matches how far people will walk to a shop. For disease reporting aggregated to a postcode, the radius should be at least the size of the postcode area or the map invents precision the data never had. Say the number out loud as a sentence — "each incident is spread over a 500 metre neighbourhood" — and if the sentence sounds wrong, the map is wrong.

Two failure modes bracket the range. Too small and the heatmap is a scatter of separate dots that adds nothing to plotting the points. Too large and everything merges into one smooth blob centred on the middle of the study area, which looks authoritative and says nothing. A practical approach is to render three candidate radii and keep the one where clusters you can independently justify are visible and clusters you cannot are not.

for radius in (250, 500, 1000):
    renderer = QgsHeatmapRenderer()
    renderer.setRadius(radius)
    renderer.setRadiusUnit(QgsUnitTypes.RenderMapUnits)
    renderer.setMaximumValue(40.0)
    renderer.setColorRamp(ramp.clone())
    layer.setRenderer(renderer)
    layer.triggerRepaint()
    # export here, then compare the three side by side

Breakdown: ramp.clone() inside the loop is not optional — a colour ramp assigned to a renderer is owned by it, and reusing the same object across renderers leads to a use-after-free that shows up as a crash rather than a wrong colour. Fixing the maximum across all three is what makes the comparison meaningful; with automatic scaling each image renormalises and the three look deceptively similar.

When to use the raster instead

The renderer is a style, so its output cannot be queried, classified, clipped or exported as data. If any of those verbs appear in the requirement, run the algorithm.

import processing

processing.run("qgis:heatmapkerneldensityestimation", {
    "INPUT": layer,
    "RADIUS": 500,
    "RADIUS_FIELD": None,
    "PIXEL_SIZE": 50,
    "WEIGHT_FIELD": "severity",
    "KERNEL": 0,                       # quartic
    "DECAY": 0,
    "OUTPUT_VALUE": 0,                 # raw values
    "OUTPUT": "/data/output/density.tif",
})

Breakdown: PIXEL_SIZE is the resolution of the output grid in layer units and is the parameter with the largest effect on both quality and run time — fifty metres against a five-hundred-metre radius is a reasonable ten-to-one ratio. KERNEL selects the falloff shape, with quartic the usual default and triweight producing a tighter core. OUTPUT_VALUE of 0 gives raw summed weights, which is what you want if the numbers will be sampled at points or compared between runs; scaled output normalises and destroys that comparability.

Once written, the raster is an ordinary layer: classify it with a singleband pseudocolour renderer, clip it to a study area, or push it through further analysis.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.16 LTR3.7QgsHeatmapRenderer complete; setRadiusUnit available.
3.22 LTR3.9Heatmap algorithm id qgis:heatmapkerneldensityestimation stable.
3.28 LTR3.9Render quality behaviour unchanged; ramp names largely stable.
3.34 LTR3.12Baseline for this page.
3.40+3.12Ramp database gains additional named ramps; check colorRampNames() before hard-coding.

Troubleshooting

  • The layer draws as a black rectangle. setColorRamp() received None because the ramp name was wrong. Check QgsStyle.defaultStyle().colorRampNames().
  • The heatmap changes every time you zoom. The radius is in millimetres. Switch to RenderMapUnits.
  • The colours change when you pan. The maximum is automatic. Set setMaximumValue() to a fixed number.
  • The heatmap is stretched vertically. The layer is in a geographic CRS, so a degree of longitude is shorter than a degree of latitude. Reproject to a projected CRS first.
  • The canvas is very slow. Lower setRenderQuality() while working and raise it for export, and consider whether the layer needs a feature filter rather than rendering every point.
  • The heatmap looks the same with and without a weight field. The weight expression returned null for most features. Wrap it in coalesce().

Conclusion

Set the radius in map units, weight with coalesce() around the field, pin the maximum so the colours mean something, and drop render quality only while you are working. When the density has to be measured rather than looked at, produce the kernel-density raster instead and treat the result as data.

Frequently Asked Questions

Can the radius vary per point? Not in the renderer, but the processing algorithm accepts RADIUS_FIELD, so each point can carry its own influence distance. That is the right route for modelling things with genuinely different reach, such as transmitters of different power.

Does the heatmap renderer respect a layer filter? Yes. Only features passing the layer's subset string or the current selection filter contribute, which makes a filtered heatmap an easy way to compare periods without duplicating the layer.

Why is the heatmap invisible over a dark basemap? The default ramps start at a dark colour with no transparency at the low end. Either choose a ramp whose first stop is transparent, or set a blend mode as described in opacity and blend modes.

Can I export the heatmap renderer to an image? Yes — it renders like any other style, so the canvas export and layout routes both work. Set render quality back to 1 first, or the export inherits the blocky preview.