Use Point Cluster and Displacement Renderers in PyQGIS

Point layers pile up. Thousands of incidents in a city centre become a solid blob at small scales; several addresses geocoded to the same building centroid sit exactly on top of each other, so a reader sees one symbol where there are five. Two QGIS renderers deal with this at display time without changing the data. The point cluster renderer merges nearby points into a single symbol showing how many it represents. The point displacement renderer spreads exactly coincident points around their shared location so each can be seen.

This recipe belongs to Graduated & Categorized Renderers. It sets up a cluster renderer with count labels and size by count, wraps an existing categorized style, configures displacement for coincident points, chooses tolerances that work across scales, and explains when to aggregate the data instead.

Cluster or displaceLeft: many points close together at a small scale. The cluster renderer replaces groups within a distance tolerance with one symbol labelled with the count, so 27 nearby incidents show as one circle with 27. Right: five points at exactly the same location. The displacement renderer arranges them on a ring around the shared position with a centre marker, so each symbol, with its own category colour, is visible.Too many to see, or exactly on top of each other?276cluster: nearby → one counted symboldisplacement: coincident → ring

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series.
  • A point layer with overlapping points — incidents, sightings, addresses — and ideally an existing style you want to keep for single points.

Cluster nearby points

The cluster renderer wraps another renderer: single points draw with the embedded renderer's symbols, and groups of points within a tolerance draw as one cluster symbol.

from qgis.core import (QgsProject, QgsPointClusterRenderer, QgsSingleSymbolRenderer,
                       QgsMarkerSymbol, QgsUnitTypes, QgsFontMarkerSymbolLayer)
from qgis.PyQt.QtGui import QColor

incidents = QgsProject.instance().mapLayersByName("incidents")[0]
single = QgsSingleSymbolRenderer(QgsMarkerSymbol.createSimple(
    {"name": "circle", "color": "#b45309", "size": "2.2", "outline_color": "#17211d", "outline_width": "0.2"}))

cluster = QgsPointClusterRenderer()
cluster.setEmbeddedRenderer(single)
cluster.setTolerance(6)
cluster.setToleranceUnit(QgsUnitTypes.RenderMillimeters)

cluster_symbol = QgsMarkerSymbol.createSimple(
    {"name": "circle", "color": "#b45309", "size": "5", "outline_color": "#fffdf7", "outline_width": "0.5"})
cluster.setClusterSymbol(cluster_symbol)

incidents.setRenderer(cluster)
incidents.triggerRepaint()

Breakdown: The embedded renderer draws points that are not clustered, so the cluster renderer can sit on top of any existing style. The tolerance is the distance within which points join a cluster, in millimetres on the map, so clustering adapts to scale automatically: zoom in and clusters break apart. The cluster symbol is an ordinary marker symbol, drawn once per cluster; the next section adds the count to it and sizes it by the number of points it represents.

Show and scale by the count

A cluster symbol that shows its count, and grows with it, tells readers how much each cluster represents. The @cluster_size variable is available inside cluster symbols for both.

Count label and size by countInside the cluster symbol the variable @cluster_size holds the number of points. A font marker layer uses it as its character, so the count is printed on the symbol. The circle's size is data-defined by an expression such as 4 plus square root of cluster size, so a cluster of 100 is visibly larger than one of 4, without huge symbols for big clusters.@cluster_size drives text and size425100size =4 + sqrt(cluster_size)area grows with the count, not linearly with it

from qgis.core import QgsProperty, QgsSymbolLayer

cluster_symbol = QgsMarkerSymbol.createSimple(
    {"name": "circle", "color": "#b45309", "size": "5", "outline_color": "#fffdf7", "outline_width": "0.5"})
cluster_symbol.symbolLayer(0).setDataDefinedProperty(
    QgsSymbolLayer.PropertySize, QgsProperty.fromExpression("4 + sqrt(@cluster_size)"))

text_layer = QgsFontMarkerSymbolLayer("Noto Sans", "0", 2.6, QColor("#17211d"))
text_layer.setDataDefinedProperty(QgsSymbolLayer.PropertyCharacter,
                                  QgsProperty.fromExpression("@cluster_size"))
text_layer.setDataDefinedProperty(QgsSymbolLayer.PropertySize,
                                  QgsProperty.fromExpression("2 + sqrt(@cluster_size) / 3"))
cluster_symbol.appendSymbolLayer(text_layer)
cluster.setClusterSymbol(cluster_symbol)
incidents.triggerRepaint()

Breakdown: Sizing by the square root of the count keeps symbol area roughly proportional to the number of points, so a cluster of 100 is not drawn 25 times wider than a cluster of 4. The font marker's character is data-defined to the count, so the number is drawn on the symbol; its size grows a little with the count to stay legible inside larger circles. Contrast matters: dark text on a mid-tone fill, or white text with a thin outline on a dark one, keeps counts readable on any basemap.

Keep categories visible

Clustering hides categories inside clusters — a red burglary and a blue traffic incident merge into one orange circle. Wrapping a categorized renderer keeps categories for single points, and an expression can colour clusters by their dominant category.

from qgis.core import QgsCategorizedSymbolRenderer, QgsRendererCategory

cats = [QgsRendererCategory(v, QgsMarkerSymbol.createSimple(
            {"name": "circle", "color": c, "size": "2.2", "outline_color": "#17211d"}), v)
        for v, c in (("burglary", "#b91c1c"), ("traffic", "#2563eb"), ("vandalism", "#15803d"))]
cluster.setEmbeddedRenderer(QgsCategorizedSymbolRenderer("type", cats))

cluster_symbol.symbolLayer(0).setDataDefinedProperty(
    QgsSymbolLayer.PropertyFillColor,
    QgsProperty.fromExpression("CASE WHEN @cluster_color IS NOT NULL THEN @cluster_color ELSE '#59645f' END"))
incidents.triggerRepaint()

Breakdown: Single points keep their category colours from the embedded categorized renderer. @cluster_color is set when all points in a cluster share the same colour, so a cluster of three burglaries stays red while a mixed cluster turns neutral grey — honest about mixing without inventing a dominant category. For per-category counts inside clusters, separate layers per category, each with its own cluster renderer, are clearer than one mixed layer.

Displace coincident points

When points share the exact same location, clustering would just count them. The displacement renderer instead arranges them around their common position so each symbol — and each category — is visible.

from qgis.core import QgsPointDisplacementRenderer

addresses = QgsProject.instance().mapLayersByName("geocoded_addresses")[0]
displace = QgsPointDisplacementRenderer()
displace.setEmbeddedRenderer(addresses.renderer().clone())
displace.setTolerance(0.5)
displace.setToleranceUnit(QgsUnitTypes.RenderMillimeters)
displace.setPlacement(QgsPointDisplacementRenderer.ConcentricRings)
displace.setCircleRadiusAddition(0)
displace.setCircleColor(QColor("#59645f"))
displace.setCircleWidth(0.2)
displace.setCenterSymbol(QgsMarkerSymbol.createSimple({"name": "circle", "color": "#17211d", "size": "1"}))
displace.setLabelAttributeName("house_no")
addresses.setRenderer(displace)
addresses.triggerRepaint()

Breakdown: A very small tolerance means only points that practically coincide are displaced, which is the right use: geocoding to building centroids, multiple records at one site. Concentric rings scale better than a single ring when many points share a location; a grid placement is another option. The centre symbol marks the true location, and the ring shows that the surrounding symbols are offsets, not real positions. A label attribute can label each displaced symbol — here the house number — which turns an unreadable pile into a readable list.

Save and reuse the clustered style

Cluster and displacement renderers, with their data-defined cluster symbols, take some effort to tune. Saving the result as a QML style makes it reusable on every similar point layer and keeps maps consistent.

from qgis.core import QgsMapLayer

ok, msg = incidents.saveNamedStyle("/data/styles/incidents_clustered.qml",
                                   categories=QgsMapLayer.Symbology)
print("saved:", ok, msg)

for name in ("incidents_2025", "incidents_2026"):
    lyr = QgsProject.instance().mapLayersByName(name)[0]
    lyr.loadNamedStyle("/data/styles/incidents_clustered.qml", categories=QgsMapLayer.Symbology)
    lyr.triggerRepaint()

Breakdown: Restricting the saved categories to symbology stores the renderer — including the embedded categorized renderer and the data-defined cluster symbol — without labels, forms or other settings, so it applies cleanly to any layer with the same type field. Loading it on yearly layers gives every year the same look, which matters when the maps are compared side by side. Styles saved this way can also live in a GeoPackage or database next to the data, as described in saving styles to a database.

Choose tolerances that work across scales

Tolerances in map units cluster the same real distance at every scale; tolerances in millimetres cluster what looks close at the current scale. For display, millimetres are almost always right.

Tolerance in screen or map unitsA tolerance of 6 millimetres clusters points that would overlap visually, so clusters break apart as you zoom in and merge as you zoom out, matching what the eye sees. A tolerance of 500 map metres always merges points within 500 metres, which over-clusters at large scales and under-clusters at small ones.Cluster what overlaps on screenmillimetresvisual overlapadapts to zoomusually rightmap unitsfixed real distancesame at every scaleanalysis, not display

A millimetre tolerance about twice the symbol size clusters exactly the points that would otherwise overlap. If clustering has an analytical meaning — "incidents within 100 m count as one hotspot" — that is an analysis, not a display choice, and belongs in a DBSCAN clustering or aggregation step whose result is stored and styled. Renderers change only the picture; the attribute table and every analysis still see all the points.

When to aggregate instead

Cluster renderers suit interactive maps where users zoom. For printed maps at a single scale, and for very large point sets, aggregating into a hexagon grid or administrative units and styling the counts is usually clearer: every area shows its count, nothing is hidden behind symbols, and the counts can be normalised by population or area. Creating a hexagon grid and counting points shows the aggregation route, and kernel density a continuous one.

QGIS version compatibility

QgsPointClusterRenderer and QgsPointDisplacementRenderer work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. The @cluster_size and @cluster_color variables are available inside cluster symbols on all these releases. On QGIS 4, enums are scoped: QgsPointDisplacementRenderer.Placement.ConcentricRings, QgsSymbolLayer.Property.Size, Qgis.RenderUnit.Millimeters.

Troubleshooting

  • Everything becomes one cluster. The tolerance is in map units or far too large; use a few millimetres.
  • Counts do not appear. The font marker's character is not data-defined to @cluster_size.
  • Categories disappear. The embedded renderer is single-symbol; embed the categorized renderer.
  • Displacement has no effect. Points are close but not coincident within the tolerance; displacement is for near-identical positions.

Conclusion

Wrap the existing renderer in a point cluster renderer with a millimetre tolerance, print @cluster_size on the cluster symbol and size it by the square root of the count, keep categories for single points and colour clusters only when they are uniform, use the displacement renderer for truly coincident points with a centre marker and labels, and aggregate into areas for print maps and analysis.

Frequently Asked Questions

Do cluster renderers change the data? No. They change only how points are drawn; queries and exports see every point.

Can I click a cluster to see its points? The identify tool returns all points in a cluster; plugins can zoom to their extent.

Does clustering work in print layouts? Yes, at the layout map's scale; check the result at the print scale.

Can I cluster only at small scales? Yes — use rule-based scale ranges, or two copies of the layer with scale-dependent visibility: clustered when zoomed out, plain when zoomed in past a threshold.

Is the heatmap renderer an alternative? For density, yes — see creating a heatmap renderer.