Dissolve Features by Attribute in PyQGIS
Dissolving is how a thousand parcels become twelve wards, or how a land-cover raster's polygonised output stops being ten thousand adjacent squares. The operation itself is one algorithm call. What makes it worth a page is what dissolve loses: attributes that were meaningful per feature, boundaries that were nearly-but-not-quite shared, and a distinction between merging geometry and summarising data that catches people at exactly the wrong moment.
This recipe belongs to Vector Data Manipulation in PyQGIS. It covers dissolving by one or several fields, keeping useful aggregates, cleaning the slivers that imprecise boundaries leave behind, and knowing when native:aggregate is the algorithm you actually wanted.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A polygon or line layer with a field that groups the features.
- Valid geometry. Dissolve on self-intersecting polygons produces unpredictable results — repair first with Find and Fix Invalid Geometries.
Dissolve by one field
import processing
processing.run("native:dissolve", {
"INPUT": "/data/parcels.gpkg|layername=parcels",
"FIELD": ["ward_code"],
"SEPARATE_DISJOINT": False,
"OUTPUT": "/data/output/wards.gpkg",
})
Breakdown: FIELD takes a list, even for a single field — passing a bare string is the most common cause of "no attribute grouping happened", because an empty or unrecognised field list dissolves everything into one feature. SEPARATE_DISJOINT decides what happens when a group's polygons do not touch: False produces one multipart feature per group, True splits it into separate single-part features. Multipart is right for administrative areas; separate parts are right when each patch is a thing in its own right, such as woodland blocks.
The output keeps the grouping field and the attribute values from the first feature in each group for every other column. Those values are arbitrary — the first feature is whichever the provider returned first — so anything you care about must be aggregated deliberately.
Dissolve by several fields
processing.run("native:dissolve", {
"INPUT": "/data/landcover.gpkg|layername=landcover",
"FIELD": ["ward_code", "cover_class"],
"OUTPUT": "/data/output/cover_by_ward.gpkg",
})
Breakdown: Multiple fields dissolve on the combination, producing one feature per distinct pair — every land-cover class within every ward. This is the shape most reporting needs, and it is why dissolving by ward alone then joining classes back rarely works: the geometry has already been merged across classes.
Keep the numbers you need
Dissolve merges shapes; it does not sum anything. When totals matter, use native:aggregate, which dissolves and aggregates in one pass.
processing.run("native:aggregate", {
"INPUT": "/data/parcels.gpkg|layername=parcels",
"GROUP_BY": '"ward_code"',
"AGGREGATES": [
{"aggregate": "first_value", "delimiter": ",", "input": '"ward_code"',
"length": 10, "name": "ward_code", "precision": 0, "type": 10},
{"aggregate": "sum", "delimiter": ",", "input": '"area_m2"',
"length": 20, "name": "total_area", "precision": 2, "type": 6},
{"aggregate": "count", "delimiter": ",", "input": '"parcel_ref"',
"length": 10, "name": "parcel_count", "precision": 0, "type": 4},
],
"OUTPUT": "/data/output/wards_summary.gpkg",
})
Breakdown: GROUP_BY is an expression, so the field name is quoted — and it can be any expression, which means grouping by left("ward_code", 2) or by a computed category is available for free. Each entry in AGGREGATES defines one output field: aggregate is the function (sum, mean, count, concatenate, first_value, maximum…), input is an expression over the source, and type is the QVariant type code — 10 for string, 6 for double, 4 for long integer. The dictionary is verbose, and building it programmatically from a small list of tuples is worth doing once in any project that dissolves regularly.
Clean up what imprecise boundaries leave
Data digitised from different sources rarely shares vertices exactly. Two polygons that look adjacent may be a millimetre apart, and dissolve will faithfully keep the gap — producing a result with hairline slivers running through it.
snapped = processing.run("native:snapgeometries", {
"INPUT": "/data/parcels.gpkg|layername=parcels",
"REFERENCE_LAYER": "/data/parcels.gpkg|layername=parcels",
"TOLERANCE": 0.05,
"BEHAVIOR": 1, # prefer aligning nodes, insert extra vertices
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
processing.run("native:dissolve", {
"INPUT": snapped, "FIELD": ["ward_code"],
"OUTPUT": "/data/output/wards.gpkg",
})
Breakdown: Snapping the layer to itself with a small tolerance pulls near-coincident vertices together so the shared boundaries genuinely coincide. Five centimetres is a reasonable starting tolerance for cadastral data in metres; too large and real detail collapses, so check the vertex count before and after. Dissolving the snapped copy then produces clean boundaries. If slivers survive, run native:fixgeometries on the result and inspect with the checks in Find and Fix Invalid Geometries.
Prove the result before publishing it
Dissolve is one of the few operations with an arithmetic check available: the total area of the output should equal the total area of the input, because merging shapes moves no ground.
from qgis.core import QgsVectorLayer
def total_area(path):
layer = QgsVectorLayer(path, "check", "ogr")
return sum(f.geometry().area() for f in layer.getFeatures()), layer.featureCount()
source_area, source_count = total_area("/data/parcels.gpkg|layername=parcels")
result_area, result_count = total_area("/data/output/wards.gpkg|layername=wards")
difference = abs(result_area - source_area) / source_area
print(f"{source_count} → {result_count} features, area differs by {difference:.4%}")
if difference > 0.0001:
raise RuntimeError("area changed by more than 0.01% — check for slivers or overlaps")
Breakdown: A difference of a few thousandths of a percent is floating-point noise and unavoidable. Anything larger has a cause worth finding: the output being smaller usually means slivers were lost where boundaries did not quite meet, and the output being larger means the inputs overlapped, so the shared area was counted twice before and once after. Both are data-quality findings rather than dissolve bugs, and both are much cheaper to discover here than in a report three weeks later. Note that geometry().area() is in the layer's map units, so this check belongs in a projected CRS — in degrees the number is meaningless, as explained in PyQGIS Script to Calculate Polygon Areas.
The feature-count line is worth printing even when the areas agree. A dissolve producing exactly as many features as it consumed means the grouping field had a distinct value per row — usually whitespace or case differences that should have been normalised first.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | SEPARATE_DISJOINT not available; dissolve always produces multipart output. |
| 3.28 LTR | 3.9 | Behaviour matches this page. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Unchanged; native:aggregate gained additional aggregate functions. |
Troubleshooting
- Everything collapsed into one feature.
FIELDwas empty, a bare string, or named a field that does not exist. It must be a list of exact field names. - The area attribute is wrong. Dissolve kept the first feature's value. Recalculate after dissolving, or use
native:aggregate. - Hairline gaps run through the result. Boundaries were not exactly shared. Snap first.
- The output has far more features than groups.
SEPARATE_DISJOINTisTrue, or the grouping field has leading or trailing whitespace producing distinct values that look identical. Trim before dissolving. - The operation is very slow. Dissolve is geometry-heavy. Filter to the features you need first, and consider dissolving in a database where PostGIS's
ST_Unionhandles it — see Load a PostGIS Query Layer in PyQGIS. - The result has null geometry for one group. An invalid input geometry in that group. Fix geometries first.
Conclusion
native:dissolve merges geometry by one or more fields and keeps an arbitrary attribute row per group; native:aggregate does the same merge while computing sums, counts and other summaries you can defend. Snap near-coincident boundaries before dissolving, pass field names as a list, and recalculate any area or length that mattered.
Frequently Asked Questions
What is the difference between dissolve and merge? Dissolve combines features within one layer by attribute. Merging combines several layers into one without touching geometry — see Merge Multiple Shapefiles in PyQGIS.
Can I dissolve lines? Yes. Lines sharing the grouping value become one multipart line, which is how a road network is reduced to one feature per route number.
How do I dissolve everything into a single feature?
Pass an empty FIELD list. That is the intended behaviour, and the reason an accidental empty list is so easy to miss.
Does dissolve remove interior rings? No. Holes that are genuinely holes survive. A hole that exists only because two neighbours did not quite meet is a sliver, and snapping is the fix.
Why did my dissolve produce a multipart feature I did not expect?
Because the group's polygons do not touch. That is the correct representation of a ward split by a river, and SEPARATE_DISJOINT: True is the option to reach for when each part should be its own feature instead.
Can I dissolve and keep the original features too? Not in one call. Run the dissolve to a new layer and keep the source — which is the normal arrangement, since the dissolved layer is usually for display or reporting while the detailed one remains authoritative.
Should I dissolve before or after reprojecting? Before, if the source CRS is the one the data was digitised in — snapping tolerances are meaningful there. Reproject the dissolved result afterwards.