Create a Proportional Symbol Map in PyQGIS

A proportional symbol map encodes a quantity in the size of a mark rather than the colour of an area, and it is the honest choice whenever the value is a count rather than a rate. Population, sales, casualties, tonnes shipped — all of these belong in a circle whose area is the number, not in a choropleth where a large sparse district shouts louder than a small dense one.

This recipe belongs to Graduated & Categorized Renderers in PyQGIS. It covers the square-root rule that makes the encoding truthful, applying it as a data-defined override, the graduated-size alternative when a legend must show discrete classes, and how to keep the largest symbols from swallowing the map.

Why the radius must follow the square rootScaling the radius directly by the value makes a circle for one hundred sixteen times the area of a circle for twenty five, so the reader sees a difference far larger than the data. Scaling the radius by the square root of the value makes the area proportional, so the visual difference matches the numeric one.Readers compare area, so area must carry the numberradius = valuewrong2510040016× the value lookslike 256× the quantityradius = √valueright2510040016× the value covers16× the area

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A point layer with a numeric count field. Polygons work too — apply the symbol to a centroid geometry generator so the circle sits on the polygon.
  • A count, not a rate. Population per square kilometre belongs in a choropleth; total population belongs here.

The scaling rule

A circle's area grows with the square of its radius. If the radius is set directly to the value, a place with four times the count looks sixteen times as big, and every reader over-reads the large values. The fix is one square root.

import math

def radius_mm(value, max_value, max_radius_mm=8.0):
    if value is None or value <= 0:
        return 0.0
    return max_radius_mm * math.sqrt(value / max_value)

Breakdown: Anchoring on the largest value keeps the biggest symbol at a size you chose rather than one the data chose. Returning zero for a null or non-positive value is deliberate: a missing count is not a small count, and drawing nothing is more honest than drawing a dot that reads as "nearly none". max_radius_mm is the single knob that controls how busy the map is, and eight millimetres is a reasonable starting point for a national map at A3.

Apply it as a data-defined size

Rather than computing per feature in Python, hand the expression to the renderer so it evaluates during drawing and stays correct when the data changes.

from qgis.core import (
    QgsProject, QgsMarkerSymbol, QgsProperty, QgsSymbolLayer,
    QgsSingleSymbolRenderer,
)

layer = QgsProject.instance().mapLayersByName("towns")[0]
maximum = layer.maximumValue(layer.fields().indexOf("population"))

symbol = QgsMarkerSymbol.createSimple({
    "name": "circle",
    "color": "#2563eb",
    "outline_color": "#1e40af",
    "outline_width": "0.3",
})
symbol.setOpacity(0.75)

marker = symbol.symbolLayer(0)
marker.setDataDefinedProperty(
    QgsSymbolLayer.PropertySize,
    QgsProperty.fromExpression(
        f'8.0 * sqrt(coalesce("population", 0) / {maximum})'
    ),
)

layer.setRenderer(QgsSingleSymbolRenderer(symbol))
layer.triggerRepaint()

Breakdown: layer.maximumValue() reads the maximum straight from the provider, which is far cheaper than iterating features and is exact for a file-based layer. Interpolating it into the expression pins the scale, so panning does not rescale the symbols — the same discipline that a fixed maximum brings to a heatmap. coalesce() protects against nulls, which would otherwise fall back silently to the symbol's static size and produce a medium circle where there is no data. setOpacity(0.75) on the symbol lets overlapping circles show through each other, which matters because they will overlap.

Note that PropertySize on a marker sets the diameter, not the radius, so the expression above produces a maximum diameter of 8 mm. Doubling the constant if you were thinking in radii is the most common off-by-two in this recipe.

Draw the big ones underneath

Overlapping circles hide each other, and by default QGIS draws features in provider order — which is usually the order they were written, i.e. arbitrary. Sorting so the largest are painted first puts the small ones on top where they remain visible.

renderer = layer.renderer()
renderer.setOrderBy(
    QgsFeatureRequest.OrderBy([
        QgsFeatureRequest.OrderByClause("population", ascending=False)
    ])
)
renderer.setOrderByEnabled(True)
layer.triggerRepaint()

Breakdown: setOrderBy() attaches a sort to the feature request the renderer issues, so it costs one ordered read rather than a Python loop. ascending=False draws the largest first, meaning they end up at the bottom of the pile. Forgetting setOrderByEnabled(True) stores the clause and ignores it — the same two-part pattern as following a map theme, and the same easy mistake.

Draw order decides which towns disappearIn arbitrary provider order a large circle can be painted over several small ones, hiding them completely. Sorting descending by the mapped value paints the largest first, so every smaller symbol lands on top of it and stays readable.Sorting is the difference between six towns and oneprovider ordertwo towns are gonelargest painted firstall four readable

The graduated-size alternative

A continuous size gives the most faithful encoding and the worst legend — a reader cannot read a value off a circle. Graduated size classes trade a little accuracy for a legend that works.

from qgis.core import QgsGraduatedSymbolRenderer, QgsClassificationQuantile

renderer = QgsGraduatedSymbolRenderer("population", [])
renderer.setSourceSymbol(symbol.clone())
renderer.setGraduatedMethod(QgsGraduatedSymbolRenderer.GraduatedSize)
renderer.setSymbolSizes(2.0, 12.0)
renderer.setClassificationMethod(QgsClassificationQuantile())
renderer.updateClasses(layer, 5)
renderer.setSymbolSizes(2.0, 12.0)

layer.setRenderer(renderer)
layer.triggerRepaint()

Breakdown: setGraduatedMethod(GraduatedSize) is what switches the renderer from varying colour to varying size — without it you get a five-colour choropleth of circles. setSymbolSizes() must be called after updateClasses() as well as before, because recomputing classes rebuilds the symbols and discards the sizes; calling it twice is ugly and reliable. Quantile classification puts an equal count in each class, which suits skewed count data far better than equal intervals, where four classes end up empty and everything lands in the first.

Each class now appears in the legend with its own circle and range label, which is the whole point. The cost is that two towns with meaningfully different populations can share a symbol.

Labelling the symbols without wrecking them

A proportional symbol map usually needs a few values written on it, and the labelling has to respect the fact that the symbols vary in size.

from qgis.core import (
    QgsPalLayerSettings, QgsVectorLayerSimpleLabeling, QgsProperty,
)

settings = QgsPalLayerSettings()
settings.fieldName = "format_number(\"population\", 0)"
settings.isExpression = True
settings.placement = QgsPalLayerSettings.OverPoint
settings.quadOffset = QgsPalLayerSettings.QuadrantOver

settings.dataDefinedProperties().setProperty(
    QgsPalLayerSettings.Show,
    QgsProperty.fromExpression(f'"population" > {maximum * 0.25}'),
)

layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
layer.setLabelsEnabled(True)
layer.triggerRepaint()

Breakdown: QuadrantOver centres the label on the point, which only works where the symbol is big enough to hold it — hence the Show expression restricting labels to the larger quarter of the range. Labelling every symbol on a proportional map is almost always wrong: the small ones cannot contain their number and the label ends up bigger than the mark it describes. format_number() inserts thousands separators, which matters more here than in most places because these numbers are the point of the map.

For the smaller symbols that still need identifying, a second labelling pass with placement = OverPoint and a positive offset puts the name beside the circle rather than inside it. QGIS supports only one labelling configuration per layer, so the usual arrangement is a rule-based labelling object with two rules — one for the large symbols labelled inside, one for the rest labelled alongside — which the label placement guide covers in more depth.

Keep the map readable

Three adjustments do most of the work when the first attempt looks like a plate of bubbles.

Cap the maximum size against the smallest gap between features rather than against the page — if two towns are 6 mm apart at print scale, an 8 mm radius guarantees overlap. Consider a square root of a square root for extremely skewed data, accepting that the encoding becomes ordinal rather than proportional, and say so in the caption. And where the largest few values dwarf everything, split them out: draw the top three as labelled symbols and the rest proportionally, which is a common newspaper technique and more honest than a scale nobody can read.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.16 LTR3.7GraduatedSize method and data-defined size both present.
3.22 LTR3.9QgsClassificationMethod subclasses replace the older integer mode constants.
3.28 LTR3.9setOrderBy on renderers stable for feature draw order.
3.34 LTR3.12Baseline for this page.
3.40+3.12Legend patch shapes let a proportional legend be drawn as nested circles.

Troubleshooting

  • Large values dominate absurdly. The radius follows the value rather than its square root. Add sqrt().
  • Symbols rescale when you pan. The maximum is being computed from the visible extent. Pin it with a literal in the expression.
  • Some symbols are a fixed medium size. Null values are falling back to the static size. Wrap in coalesce().
  • Small towns are invisible. Big symbols are painted last. Set an order-by clause descending and enable it.
  • The graduated renderer changed colours instead of sizes. setGraduatedMethod(GraduatedSize) was not called.
  • Sizes reverted after classification. updateClasses() rebuilt the symbols. Call setSymbolSizes() again afterwards.

Conclusion

Take the square root, pin the maximum, protect against nulls, and sort the draw order descending. Choose a continuous size when accuracy matters most and graduated classes when the legend does, and cap the largest symbol against the spacing of the data rather than the size of the page.

Frequently Asked Questions

Can I do this with polygons? Yes — add a geometry generator symbol layer with point_on_surface($geometry) and a marker sub-symbol carrying the size expression. The polygon keeps its own fill underneath.

How do I build a legend showing nested circles? QGIS 3.40 and newer support custom legend patch shapes; before that, the usual approach is to draw the nested-circle key as a layout picture or annotation rather than as a legend item.

Should the symbol be a circle? Circles are the easiest to compare and the standard choice. Squares are marginally easier to judge accurately but read as less map-like; anything more elaborate makes area judgement harder, not easier.

What if some values are negative? Size cannot encode sign. Map the absolute value to size and the sign to colour — a two-colour scheme with the same size expression wrapped in abs() is the usual solution.