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.

What dissolve does to geometry and to the tableOn the left, twelve small polygons are grouped into three colours by their ward attribute, with a twelve-row attribute table. On the right, each colour group has become a single polygon with the internal boundaries removed, and the table has three rows. A note marks that the per-parcel reference and area columns no longer have a meaningful value.Boundaries merge; per-feature attributes do not survive12 parcels, 3 wardsevery internal edge is stored twice3 wardsone boundary per ward, no internal edgesdissolve

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.

Dissolve keeps one arbitrary row; aggregate computesThree input parcels with areas and references are shown. Dissolve produces one feature whose area field still holds the first parcel's area, which is misleading. Aggregate produces one feature whose total area is the sum and whose count field records how many parcels contributed.The attribute left behind by dissolve is not a totalinputP-101 · 1 200 m²P-102 · 3 400 m²P-103 · 900 m²all in ward W3native:dissolvegeometry: mergedparcel_ref: P-101area_m2: 1 200looks like a total, is notnative:aggregategeometry: mergedtotal_area: 5 500parcel_count: 3the numbers mean something

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.

What a change in total area is telling youComparing the total area before and after a dissolve gives three outcomes. An identical total means the merge was clean. A smaller total means slivers were lost where boundaries did not coincide. A larger total means the inputs overlapped, so shared ground was counted twice before the merge and once after.Merging shapes should not move any groundidenticalbefore 4 812 400 m²after 4 812 400 m²clean mergesmaller afterbefore 4 812 400 m²after 4 809 100 m²slivers lost — snap firstlarger afterbefore 4 812 400 m²after 4 861 900 m²inputs overlapped

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.22 LTR3.9SEPARATE_DISJOINT not available; dissolve always produces multipart output.
3.28 LTR3.9Behaviour matches this page.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Unchanged; native:aggregate gained additional aggregate functions.

Troubleshooting

  • Everything collapsed into one feature. FIELD was 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_DISJOINT is True, 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_Union handles 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.