Data-Defined Symbol Size with an Expression in PyQGIS

A proportional-symbol map — bigger circle, bigger value — is one of the few thematic techniques that reads instantly without a legend. In PyQGIS it is a data-defined property: instead of setting a fixed size on the symbol, you bind an expression that QGIS re-evaluates for every feature at render time. Nothing is written to the layer, so the same data can carry three different sizings in three different projects.

This page is a focused recipe within Working with QGIS Expressions in PyQGIS, and it pairs with Programmatic Layer Styling on the cartography side. It covers binding the property, choosing between linear and area-true scaling, handling NULL and outliers, and producing a legend that matches what the map actually draws.

Scaling the radius versus scaling the areaThree towns with populations of four thousand, twenty-eight thousand and ninety-six thousand are drawn twice. In the upper row scale_linear maps population onto the radius, so the largest circle looks far more than twenty-four times the smallest because its area grows with the square. In the lower row scale_exp with an exponent of one half maps population onto the area, so the visual magnitudes match the data.The eye reads area, not radiusscale_linear → radiusexaggerates large values4 20028 50096 000scale_exp(…, 0.5) → areavisual size matches the data4 20028 50096 000

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A point layer with a numeric attribute to size by. The technique works on line width and polygon outline too, but proportional circles are the canonical case.
  • A single-symbol renderer on the layer. If it is currently categorized or graduated, the symbol lookup below changes — see Graduated and Categorized Renderers.

Bind the expression to symbol size

The property lives on a symbol layer, not the symbol: a symbol can stack several layers, and each has its own overridable properties.

from qgis.core import QgsProject, QgsProperty, QgsSymbolLayer

layer = QgsProject.instance().mapLayersByName("towns")[0]
symbol_layer = layer.renderer().symbol().symbolLayer(0)

symbol_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertySize,
    QgsProperty.fromExpression('scale_linear("population", 0, 100000, 2, 12)'),
)

layer.triggerRepaint()

Breakdown: renderer().symbol() works for a single-symbol renderer; a categorized renderer has one symbol per category and needs renderer().symbols(QgsRenderContext()) instead. symbolLayer(0) is the bottom-most layer of the symbol, which for a plain marker is the only one. scale_linear(value, in_min, in_max, out_min, out_max) clamps outside the input range, so a population of 200 000 still renders at 12 mm rather than running off the map. triggerRepaint() is what makes the canvas redraw — without it, the change is applied but invisible until something else forces a refresh.

Scale the area, not the radius

scale_linear on size maps the value onto the marker's diameter. Because a circle's area grows with the square of its diameter, a value that is four times larger looks sixteen times bigger. For an honest proportional-symbol map, scale so that area is proportional — which means taking a square root.

from qgis.core import QgsProperty, QgsSymbolLayer

symbol_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertySize,
    QgsProperty.fromExpression('scale_exp("population", 0, 100000, 2, 12, 0.5)'),
)
layer.triggerRepaint()

Breakdown: scale_exp() takes the same five arguments as scale_linear() plus an exponent. An exponent of 0.5 is a square root, which makes the rendered area proportional to the value — the standard cartographic convention for proportional symbols. Exponents above 1 exaggerate large values further, which is occasionally useful for emphasis but should be declared in the caption if you do it.

The choice matters more than it sounds. With linear radius scaling, a reader comparing the largest and smallest circles on the map above would judge the ratio at roughly 24:1 by area when the underlying data says 23:1 by value — coincidentally close. Push the range wider and the distortion becomes severe.

Handle NULL and outliers

Real data has empty cells and a handful of values far outside the useful range. Both need an explicit decision, because the defaults are unhelpful: a NULL produces a zero-size marker that is invisible, and a single extreme value flattens everything else.

expression = """
scale_exp(
  coalesce("population", 0),
  0,
  coalesce(maximum("population"), 1),
  2, 14, 0.5
)
"""

symbol_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertySize, QgsProperty.fromExpression(expression)
)

Breakdown: coalesce("population", 0) turns an empty cell into a zero, which renders at the minimum size rather than vanishing — a visible small dot is far better than a silently missing town. maximum("population") is an aggregate function evaluated across the layer, so the upper bound adapts to the data instead of being hard-coded; wrapping it in coalesce(..., 1) prevents a divide-by-zero on an empty layer. The triple-quoted Python string keeps the expression readable, and the engine ignores the newlines.

For genuine outliers, clamping at a percentile reads better than clamping at the maximum. There is no percentile function in the expression engine, so compute the bound in Python and format it into the expression:

from qgis.core import QgsExpression
import statistics

values = sorted(f["population"] for f in layer.getFeatures() if f["population"] is not None)
cap = values[int(len(values) * 0.95)] if values else 1

expression = (
    f'scale_exp(coalesce("population", 0), 0, {cap}, 2, 14, 0.5)'
)
symbol_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertySize, QgsProperty.fromExpression(expression)
)

Breakdown: Reading the values once in Python and taking the 95th percentile gives a bound that ignores the handful of extreme outliers. Because scale_exp() clamps, the five per cent above the cap all render at the maximum size — which is exactly the intent, and should be mentioned in the legend. Formatting a computed number into an expression is safe; formatting a computed string is not, which is why the earlier example used QgsExpression.quotedValue().

Make the legend match

A data-defined size renders correctly but produces a legend showing a single arbitrary symbol, because QGIS has no way to know which sizes are meaningful. A data-defined size legend fixes that.

Default legend versus a data-defined size legendOn the left, the default legend entry for a data-defined symbol shows a single medium circle labelled only with the layer name, giving the reader no way to interpret sizes. On the right, a data-defined size legend shows three nested circles of increasing size, each annotated with the population value it represents.Without a size legend the reader cannot decode the mapdefault legend entrytownsone size, no scale referencedata-defined size legend100 00040 00010 000nested circles the reader can measure against

from qgis.core import QgsDataDefinedSizeLegend, QgsProperty

size_legend = QgsDataDefinedSizeLegend()
size_legend.setLegendType(QgsDataDefinedSizeLegend.LegendCollapsed)
size_legend.setTitle("Population")

renderer = layer.renderer()
renderer.setDataDefinedSizeLegend(size_legend)
layer.triggerRepaint()
layer.emitStyleChanged()

Breakdown: LegendCollapsed produces the nested-circle form shown above; LegendSeparated lists each class on its own row, which suits a narrow legend column. setTitle() labels the group so the reader knows what the sizes mean. emitStyleChanged() is what tells the layer tree to rebuild the legend — triggerRepaint() alone redraws the map but leaves the old legend in place, which is a confusing half-update.

Beyond size: width, rotation and offset

PropertySize is one slot among many. The same QgsProperty mechanism drives every overridable symbol property, and a few of them solve problems that would otherwise need a separate layer.

Four properties an expression can driveFour panels each show a different data-defined property. Line width renders three road segments at increasing thickness from a volume field. Rotation renders three arrow markers pointing in different directions from a bearing field. Fill colour renders three polygons in a ramp from a value field. Offset renders two parallel lines shifted to either side of a centreline from a side field.Every overridable property takes the same QgsPropertyPropertyStrokeWidthwidth ← "volume"PropertyAngleangle ← "bearing"PropertyFillColorramp_color(…, "score")PropertyOffsetoffset ← "side"

from qgis.core import QgsProperty, QgsSymbolLayer

roads = QgsProject.instance().mapLayersByName("roads")[0]
line_layer = roads.renderer().symbol().symbolLayer(0)

line_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertyStrokeWidth,
    QgsProperty.fromExpression('scale_linear("daily_volume", 0, 40000, 0.3, 4)'),
)
line_layer.setDataDefinedProperty(
    QgsSymbolLayer.PropertyStrokeColor,
    QgsProperty.fromExpression(
        "ramp_color('Reds', scale_linear(\"congestion\", 0, 1, 0, 1))"
    ),
)
roads.triggerRepaint()

Breakdown: PropertyStrokeWidth is the line-width slot, and here scale_linear is the right choice rather than scale_exp — the eye reads a line's thickness as a single dimension, so no area correction is needed. ramp_color() takes a named ramp and a position between 0 and 1, which is why the inner scale_linear normalises the congestion value into that range. Two properties on one symbol layer is perfectly normal; they are evaluated independently per feature. Which slots a symbol layer exposes varies by type, and symbol_layer.dataDefinedProperties().propertyKeys() lists the ones currently set.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Identical API. QgsSymbolLayer.PropertySize and scale_exp() both long-standing.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsSymbolLayer.Property.Size is the newer enum spelling; the old form still resolves.

QgsProperty, QgsDataDefinedSizeLegend and the scaling functions are unchanged across the 3.x line.

Troubleshooting

  • All markers render at the same size. The property was set on the symbol rather than a symbol layer, or the renderer is not single-symbol so renderer().symbol() returned the wrong object.
  • Some markers are invisible. Their value is NULL, which evaluates to zero size. Wrap the field in coalesce().
  • Every marker is the maximum size. The input range is too narrow — scale_linear("population", 0, 100, ...) clamps everything above 100 to the top. Check the actual data range first.
  • The change does not appear. triggerRepaint() was not called, or the layer is cached. Add emitStyleChanged() too when the legend should update.
  • The map reads as more extreme than the data. Radius is being scaled linearly. Switch to scale_exp(..., 0.5) so area carries the value.
  • The legend still shows one symbol. setDataDefinedSizeLegend() was applied but emitStyleChanged() was not, so the layer tree is showing a stale legend.

Conclusion

A data-defined size is two objects — a QgsProperty holding the expression and the QgsSymbolLayer.PropertySize slot it binds to — plus one cartographic decision. Scale the area rather than the radius, guard NULL with coalesce(), cap the range at a percentile rather than the maximum, and attach a QgsDataDefinedSizeLegend so the reader can decode what they are looking at.

Frequently Asked Questions

Why do all my symbols come out the same size? The property was almost certainly applied to the symbol instead of one of its symbol layers, or the renderer is categorized rather than single-symbol. Fetch the symbol layer with renderer().symbol().symbolLayer(0) and set the property there.

Should I use scale_linear or scale_exp?scale_exp with an exponent of 0.5 for proportional symbols, because it makes the rendered area proportional to the value, which is what a reader actually perceives. scale_linear is fine for line width, where the eye reads the single dimension directly.

How do I stop NULL values disappearing from the map? Wrap the field in coalesce("population", 0). A NULL otherwise evaluates to a zero-size marker, which draws nothing at all and looks like missing data rather than a missing value.

Does a data-defined size change my data? No. The expression is evaluated at render time and nothing is written to the layer. Removing the property restores the fixed symbol size immediately.