Count and Summarise Features by Attribute in PyQGIS

"How many parcels per zoning class?" is the most common reporting question in GIS, and the naive answer — iterate the layer and increment a dictionary — is correct but usually reads far more data than it needs. On a polygon layer the geometry decoding alone can account for most of the runtime, and none of it contributes to a count.

This page is a focused recipe within Attribute Tables and Field Management in PyQGIS. It covers counting efficiently, computing sums and means alongside the counts, letting the provider do the work when it can, and producing a summary as a layer rather than console output.

What a narrowed feature request avoids readingA full read of a polygon layer transfers geometry, which dominates, plus every attribute column. Adding the NoGeometry flag removes the geometry entirely, and restricting the attribute subset to the single category column removes the remaining unused columns, leaving a small fraction of the original data volume.A count needs one column — the default read fetches everythinggetFeatures()geometryother colszoning+ NoGeometryother colszoning+ attribute subsetzoningless data transferredmore

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A vector layer with a categorical field to group by and, for the aggregate examples, a numeric field.
  • Familiarity with feature requests — see Working with QGIS Expressions in PyQGIS.

Count per category

collections.Counter fed by a narrowed request is the shortest correct form.

from collections import Counter
from qgis.core import QgsFeatureRequest, QgsProject

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

request = QgsFeatureRequest()
request.setFlags(QgsFeatureRequest.NoGeometry)
request.setSubsetOfAttributes(["zoning"], layer.fields())

counts = Counter(f["zoning"] for f in layer.getFeatures(request))

for zoning, count in counts.most_common():
    print(f"{zoning or '(no value)':<16} {count:>6}")
print(f"{'total':<16} {sum(counts.values()):>6}")

Breakdown: NoGeometry tells the provider not to read or decode geometry, which on a polygon layer is typically the largest single cost of iteration. setSubsetOfAttributes() narrows the columns to the one being counted. Counter consumes a generator, so nothing is held in memory beyond the tally itself. The or '(no value)' renders NULL readably instead of printing None, and keeps NULL visible as its own category rather than hiding it.

Sum and average alongside the count

One pass can produce several statistics. Building a dictionary of accumulators keeps it readable and still single-pass.

from collections import defaultdict
from qgis.core import QgsFeatureRequest, QgsProject

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

request = QgsFeatureRequest()
request.setFlags(QgsFeatureRequest.NoGeometry)
request.setSubsetOfAttributes(["zoning", "assessed_value"], layer.fields())

stats = defaultdict(lambda: {"n": 0, "total": 0.0, "valued": 0})

for feature in layer.getFeatures(request):
    entry = stats[feature["zoning"]]
    entry["n"] += 1
    value = feature["assessed_value"]
    if value is not None:
        entry["total"] += value
        entry["valued"] += 1

for zoning, entry in sorted(stats.items(), key=lambda kv: -kv[1]["n"]):
    mean = entry["total"] / entry["valued"] if entry["valued"] else 0
    print(f"{zoning or '(none)':<14} n={entry['n']:>5}  "
          f"total={entry['total']:>14,.0f}  mean={mean:>10,.0f}")

Breakdown: Counting n and valued separately is the point — the mean must divide by the number of features that actually had a value, not by the number in the category, or every NULL silently drags the average down. defaultdict with a factory avoids a membership check on every iteration. Sorting by descending count puts the categories that matter at the top of the report.

Let the provider aggregate

For a database-backed layer, QgsVectorLayer.aggregate() can push the work down to the server, which never transfers the rows at all.

from qgis.core import QgsAggregateCalculator, QgsProject

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

total, ok = layer.aggregate(QgsAggregateCalculator.Sum, "assessed_value")
mean, _ = layer.aggregate(QgsAggregateCalculator.Mean, "assessed_value")
distinct, _ = layer.aggregate(QgsAggregateCalculator.CountDistinct, "zoning")

print(f"sum={total:,.0f} mean={mean:,.0f} distinct zonings={distinct}")

params = QgsAggregateCalculator.AggregateParameters()
params.filter = '"zoning" = \'residential\''
res_total, _ = layer.aggregate(
    QgsAggregateCalculator.Sum, "assessed_value", parameters=params
)
print(f"residential total={res_total:,.0f}")

Breakdown: aggregate() returns a (value, ok) tuple — the second element is False when the aggregate could not be computed, for example on a field of the wrong type. AggregateParameters.filter is an expression that restricts the rows, so one call can answer a per-category question without a Python loop. On PostGIS this becomes a single SQL aggregate; on a file-based provider QGIS iterates internally, which is still faster than doing it in Python.

Produce a summary layer

Console output is fine for exploration. A report or a join needs a table, and native:statisticsbycategories produces one directly.

From features to a one-row-per-category summaryEight parcel features carrying a zoning value and an assessed value are grouped by the zoning field. The statistics by categories algorithm produces a table with one row per distinct zoning value, each holding the number of features, the sum of the assessed values and their mean.One row per category, ready to join back or exportparcelsresidential 420 000commercial 1 200 000residential 380 000industrial 760 000residential 455 000commercial 980 000statisticsbycategoriesCATEGORIES_FIELD_NAME= zoningsummary tablecategorycountsumresidential31 255 000commercial22 180 000industrial1760 000

import processing

summary = processing.run("qgis:statisticsbycategories", {
    "INPUT": "/data/parcels.gpkg|layername=parcels",
    "VALUES_FIELD_NAME": "assessed_value",
    "CATEGORIES_FIELD_NAME": ["zoning"],
    "OUTPUT": "/data/output/zoning_summary.gpkg",
})["OUTPUT"]

for feature in summary.getFeatures():
    print(feature["zoning"], feature["count"], feature["sum"], feature["mean"])

Breakdown: CATEGORIES_FIELD_NAME takes a list, so passing two fields groups by their combination — zoning and district, for example. VALUES_FIELD_NAME is the numeric field being summarised; omit it and you get counts only. The output carries count, unique, min, max, sum, mean, median, stddev and the quartiles, which is usually more than you need but costs nothing extra. Because the result is a layer, it can be joined straight back onto the source polygons — see Join Attributes by Field Value in PyQGIS — or exported with Export an Attribute Table to CSV.

Count within a spatial extent

A summary is often only wanted for part of the map — the current view, a study area, a single district. Adding a rectangle or a geometry filter to the request keeps the count spatial without loading anything extra.

Counting the whole layer, a rectangle, and an exact areaThree panels over the same scatter of parcels. The first counts every feature in the layer. The second restricts to a bounding rectangle, which is fast but includes corners outside the study area. The third restricts to the exact study-area polygon, which requires a second geometric test and returns a smaller, correct count.Rectangle is fast; the polygon is correctwhole layer10 featuressetFilterRect()7 — includes cornersexact polygon4 — the real answer

from collections import Counter
from qgis.core import QgsFeatureRequest, QgsProject

parcels = QgsProject.instance().mapLayersByName("parcels")[0]
areas = QgsProject.instance().mapLayersByName("study_areas")[0]
study = next(areas.getFeatures()).geometry()

request = QgsFeatureRequest().setFilterRect(study.boundingBox())
request.setSubsetOfAttributes(["zoning"], parcels.fields())

counts = Counter(
    f["zoning"]
    for f in parcels.getFeatures(request)
    if f.geometry().intersects(study)
)
print(dict(counts))

Breakdown: setFilterRect() narrows the read to the study area's bounding box, which the provider can satisfy from its own spatial index. The if f.geometry().intersects(study) test then removes the features that fell in the box but outside the actual polygon — the same two-stage pattern described in Test Whether Two Geometries Intersect in PyQGIS. Note that NoGeometry cannot be used here, because the exact test needs the geometry; the attribute subset still applies.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Identical API. Algorithm ID is qgis:statisticsbycategories in all 3.x.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsFeatureRequest.Flag.NoGeometry is the newer spelling; the old constant still resolves.

QgsAggregateCalculator and the feature-request flags are unchanged across 3.x.

Troubleshooting

  • The count is slow on a polygon layer. Geometry is being decoded. Add QgsFeatureRequest.NoGeometry and a column subset.
  • None appears as a category. That is a NULL in the data, and keeping it visible is usually right. Render it as '(no value)' rather than dropping it.
  • The mean is lower than expected. NULL values were counted in the denominator. Track the number of non-null values separately and divide by that.
  • aggregate() returns (None, False). The field is the wrong type for that aggregate — Sum on a text column, for example — or the field name is misspelled.
  • The summary layer has no sum column. VALUES_FIELD_NAME was omitted, so only count-based statistics were produced.
  • Counts differ from the attribute table. A filter or a subsetString is active on the layer. Check layer.subsetString() before trusting the number.

Conclusion

Narrow the request before you count — NoGeometry plus an attribute subset is usually a large saving for a one-line change. Track non-null counts separately when computing means, push aggregates down to the provider where the data lives in a database, and produce a summary layer rather than console output whenever the answer needs to be joined, exported or mapped.

Frequently Asked Questions

What is the fastest way to count features per category? A QgsFeatureRequest with the NoGeometry flag and setSubsetOfAttributes() limited to the category column, fed into collections.Counter. Geometry decoding is usually the dominant cost and contributes nothing to a count.

How do I stop NULL values skewing my average? Count the features with a non-null value separately from the total count in the category, and divide the sum by that. Dividing by the category size treats every NULL as a zero.

When should I use aggregate() instead of a Python loop? When the layer is database-backed. QgsVectorLayer.aggregate() can push the computation to the server so no rows are transferred. On file-based providers it still avoids the Python-level loop.

Why does my count differ from the number in the attribute table? A provider-level filter is probably active on the layer. layer.subsetString() behaves like a permanent WHERE clause and affects iteration, rendering and the attribute table alike, so a count taken while one is set reflects the filtered subset rather than the whole dataset. Print layer.subsetString() before trusting a total, and clear it with layer.setSubsetString("") if it was left behind by earlier code.

How do I group by two fields at once? Pass both names in the CATEGORIES_FIELD_NAME list of qgis:statisticsbycategories. The output has one row per distinct combination of the two values.

Does featureCount() respect a filter? Yes — it reflects any active subsetString, so it can differ from the number of rows in the underlying file.

Is Counter faster than a plain dictionary? Marginally, and it is considerably clearer to read.

Can I count features without iterating at all?layer.featureCount() gives the total directly from the provider, which is instant. Anything broken down by category still needs a pass over the values, but restricting that pass to one column keeps it cheap.