Use Aggregate Expressions in PyQGIS
Most QGIS expressions look at one feature. The aggregate family looks at many — every feature in a layer, every feature in a group, or every related child record — and returns one number. That makes them the shortest route from "each parcel's area" to "each parcel's share of its district's area", with no join, no temporary layer and no Python loop.
This recipe belongs to Working with QGIS Expressions. It covers aggregate and relation_aggregate, grouping and filtering, evaluating them from Python with the right context, and where they become too slow to use.
Prerequisites
- QGIS 3.34 LTR or newer.
- A layer to summarise, and — for
relation_aggregate— a defined relation, as covered in defining layer relations.
The aggregate function
from qgis.core import QgsExpression, QgsExpressionContext, QgsExpressionContextUtils
layer = QgsProject.instance().mapLayersByName("parcels")[0]
expression = QgsExpression(
"aggregate(layer:='parcels', aggregate:='sum', expression:=$area)"
)
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
expression.prepare(context)
print(expression.evaluate(context))
Breakdown: The named-argument form (layer:=, aggregate:=) is worth using even though positional works, because the argument order is easy to misremember and a silent mis-order gives a plausible wrong number. layer takes a layer name or id. prepare() is what lets the expression cache the aggregate rather than recomputing it, and skipping it is the difference between one scan and one scan per evaluation. globalProjectLayerScopes builds the full context — global variables, project variables and the layer's own — which is what makes @project_folder and layer fields resolve.
The available aggregates are the ones you expect — sum, mean, median, min, max, count, count_distinct, stdev, concatenate — plus the geometry ones, collect and array_agg.
Grouping and filtering
The two optional arguments do very different things and have very different costs.
"""
aggregate(
layer:='parcels',
aggregate:='sum',
expression:=$area,
group_by:="district"
)
"""
Breakdown: group_by partitions the layer once and caches a value per group, then hands each feature the value for its group. That is what makes "this parcel's district total" cheap: one scan for the whole layer regardless of how many features evaluate it. The grouping expression is evaluated on the aggregated layer's features, so it must reference that layer's fields.
"""
aggregate(
layer:='parcels',
aggregate:='sum',
expression:=$area,
filter:="use_class" = attribute(@parent, 'use_class')
)
"""
Breakdown: filter is evaluated per calling feature, which means a fresh scan for every feature that evaluates the expression — quadratic behaviour, and the reason an expression that is instant on a hundred features takes minutes on ten thousand. @parent is how the filter reaches the calling feature's values, and attribute(@parent, 'use_class') is the safe form because a bare field name inside the filter refers to the aggregated layer. Where a filter is really a grouping, rewrite it as group_by and the cost collapses.
Aggregating related records
"""
relation_aggregate(
relation:='inspections_parcel_fk',
aggregate:='max',
expression:="inspected_on"
)
"""
Breakdown: relation_aggregate walks a defined relation from the current parent feature to its children and summarises them, which is how a parcel shows the date of its most recent inspection without a join. The relation argument is the relation's id, not its name — the two differ, and the id is what QgsProject.instance().relationManager().relations() gives you. Because the relation carries the join fields, there is no filter to write and no chance of getting it wrong. This is the function that makes attribute forms genuinely useful, and it is only available where a relation exists.
Evaluating from Python over every feature
expression = QgsExpression(
"$area / aggregate('parcels', 'sum', $area, group_by:=\"district\") * 100"
)
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
expression.prepare(context)
for feature in layer.getFeatures():
context.setFeature(feature)
share = expression.evaluate(context)
if expression.hasEvalError():
print("error:", expression.evalErrorString())
break
print(feature["parcel_id"], f"{share:.2f}% of its district")
Breakdown: The aggregate cache lives on the context, so building it once outside the loop is what makes this a single scan. setFeature is the only thing that changes per iteration. Checking hasEvalError() matters because expression evaluation returns None on failure rather than raising, and a None propagating into arithmetic gives a TypeError several lines later that names nothing useful. The mechanics of evaluating expressions generally are covered in evaluating a QGIS expression in PyQGIS.
Overlay functions: aggregating by geometry
There is a second family that summarises by spatial relationship rather than by attribute, and it is often what people reach for aggregate to fake.
"""
array_sum(
overlay_intersects(
layer:='buildings',
expression:="floor_area"
)
)
"""
Breakdown: The overlay_* functions — overlay_intersects, overlay_within, overlay_contains, overlay_nearest, overlay_touches and the rest — return an array of values from the features of another layer that satisfy the relationship with the current feature. Wrapping that in array_sum, array_length or array_max turns it into a number, so "the total floor area of buildings inside this parcel" is one expression with no spatial join.
They use a spatial index, so they are far faster than the filter form of aggregate with a geometry test, and they are still per-feature scans of the index rather than a single pass. On a layer of a few thousand features against another few thousand they are comfortable; on hundreds of thousands they are not, and a real spatial join is the right tool.
One behaviour worth knowing: overlay_nearest takes an optional limit and max_distance, and leaving the distance unbounded means every feature searches the whole index. Bounding it is usually both faster and more correct, since a "nearest" hundreds of kilometres away is rarely the answer anyone wanted.
When to stop using aggregates
Aggregates are expressions, which means they are re-evaluated whenever anything asks for them — on every render, on every attribute table refresh, on every export. That is fine for a virtual field on a few thousand features and untenable on a large layer being panned around.
The threshold in practice is roughly: a group_by aggregate over tens of thousands of features is fine; a filter aggregate over more than a few thousand is not. Past that, compute the value once into a real field and store it, which turns an expensive expression into a cheap column read. native:joinbyattribute after a summary, or a short Python loop writing a field, both do the job — see counting features by attribute.
The other reason to stop is reproducibility: a stored field is in the data and travels with it, while a virtual field is in the project and does not.
QGIS version compatibility
aggregate and relation_aggregate have been present since QGIS 2.16 and unchanged through 3.44. Named arguments in expression functions arrived in 3.0. QgsExpression.prepare has existed throughout and is still the difference between a cached and an uncached aggregate. The array_agg aggregate needs 3.0 or newer.
Troubleshooting
- The result is always
NULL. The layer name does not match, or the expression references a field that does not exist on the aggregated layer. - The expression is extremely slow. A
filterargument re-scanning per feature. Rewrite asgroup_byif the filter is an equality on a shared value. @parentis not recognised. It only exists inside an aggregate'sfilterorexpressionargument.relation_aggregatereturns nothing. The relation id is wrong, or the relation is defined in the other direction.- Different values from the attribute table and from a script. The script rebuilt the context per feature and the cache was discarded, or the layer has a subset string applied in one case and not the other.
- A
TypeErroron the returned value. Evaluation failed and returnedNone; checkhasEvalError().
Conclusion
Use group_by rather than filter whenever the filter is really a grouping, build the context once and prepare the expression, check for evaluation errors explicitly, and materialise into a real field once the layer is large enough that the scan cost matters. Aggregates replace a surprising amount of Python with one expression, right up to the point where they should not.
Frequently Asked Questions
Can an aggregate reference a layer that is not in the project? No. The layer argument resolves against the project's layers by name or id, so the layer must be loaded.
Does count include features filtered out by a subset string?
No — the aggregate sees the layer as the provider presents it, so a subset string restricts it. That is often useful and occasionally surprising.
How do I aggregate geometry?collect returns a combined geometry, which is how "the union of this district's parcels" is expressed. It is memory-hungry on large groups.
Can I use an aggregate in a data-defined symbol property?
Yes, and it is a good use of group_by — sizing a symbol by its share of a category is one expression. Expect it to be evaluated on every render, so keep the group count modest.