How to Dissolve Polygons in Python (GeoPandas)

On this page

Problem statement

A dissolve merges polygons that share a value — districts into regions, parcels into zones, land-use patches into classes — and summarises their attributes on the way. In GeoPandas it is one call, dissolve(by=...), and its defaults make three decisions you may not want. Rows whose key is null disappear. Every column you did not name an aggregation for keeps the value of whichever row came first. And the merged shape of a group is whatever the union gives: often several parts, sometimes with holes, never "one polygon per region" by guarantee.

This page dissolves Germany's 400 districts into its 16 Länder, France's départements into regions with population summed, and the Isle of Wight's OpenStreetMap land use by class, and measures each of those decisions.

Quick answer

Dissolve groups rows by a shared attribute and merges their geometries, removing internal boundaries. Before — many polygons After — dissolved by group spatialworkflow.io
Dissolve merges polygons that share an attribute, removing the boundaries between them.

Group by the field and name an aggregation for every column you keep:

import geopandas as gpd

gdf = gpd.read_file("data/de_districts.shp")
dissolved = gdf.dissolve(by="region").reset_index()
print(len(gdf), "districts ->", len(dissolved), "regions")
400 districts -> 16 regions

This creates one merged polygon or multipart geometry for each unique region value — here Germany's 400 districts (NUTS 3) into its 16 Länder. If you want to summarize other fields at the same time, pass aggregation functions with aggfunc.

Inspect the key, dissolve, aggregate on purpose

Polygons sharing an attribute value merged into one geometry. shared attribute, shared shapeBeforeAABB4 polygons, 2 regionsAfter dissolve(by="region")AB2 polygons, borders goneInternal boundaries disappear; the outer edge is preserved exactly spatialworkflow.io
Dissolve is a groupby that merges geometry as well as attributes.

Load polygon data into GeoPandas

Start by reading a polygon layer from a shapefile, GeoJSON, or GeoPackage.

import geopandas as gpd

gdf = gpd.read_file("data/de_districts.shp")

print(gdf.head())
print(gdf.geom_type.unique())
print(gdf.crs)
  nuts_id  ...                                           geometry
0   DE914  ...  POLYGON ((10.75931 52.79583, 11.00878 52.49675...
1   DE916  ...  POLYGON ((10.20409 52.00723, 10.28617 52.03383...
2   DE917  ...  POLYGON ((10.93454 52.4718, 11.04657 52.37427,...
3   DE918  ...  POLYGON ((10.06553 51.92736, 10.15371 51.79354...
4   DE91A  ...  POLYGON ((10.29191 52.4479, 10.42407 52.33023,...

[5 rows x 4 columns]
<ArrowStringArray>
['Polygon', 'MultiPolygon']
Length: 2, dtype: str
EPSG:4326

Confirm it is polygons, and valid:

if not all(geom_type in ["Polygon", "MultiPolygon"] for geom_type in gdf.geom_type.unique()):
    raise ValueError("Input layer must contain polygon geometries.")
print(f"{len(gdf)} polygons, {(~gdf.is_valid).sum()} invalid")
400 polygons, 0 invalid

Inspect the attribute used for dissolving

Before running a dissolve, inspect the field you want to group by.

print(gdf.columns)
print(gdf["region"].value_counts(dropna=False))
Index(['nuts_id', 'name', 'region', 'geometry'], dtype='str')
region
Bayern                    96
Nordrhein-Westfalen       53
Niedersachsen             45
Baden-Württemberg         44
Rheinland-Pfalz           36
Hessen                    26
Thüringen                 22
Brandenburg               18
Schleswig-Holstein        15
Sachsen-Anhalt            14
Sachsen                   13
Mecklenburg-Vorpommern     8
Saarland                   6
Bremen                     2
Berlin                     1
Hamburg                    1
Name: count, dtype: int64

Every distinct spelling becomes its own group, so stray spaces or case differences split a region in two. Stripping spaces is always safe; changing case is not, because str.title() rewrites names that were already right:

import pandas as pd

print(gdf["region"].str.strip().nunique(), "regions after strip")
print(pd.Series(["Nordrhein-Westfalen", "Baden-Württemberg", "Provence-Alpes-Côte d’Azur"]).str.title().tolist())
16 regions after strip
['Nordrhein-Westfalen', 'Baden-Württemberg', 'Provence-Alpes-Côte D’Azur']

The apostrophe in the French region name ends a "word" for str.title(), so d’Azur becomes D’Azur — a new spelling that would split one group into two if only some rows were cleaned. Normalise case with str.casefold() into a separate key column if you must, and keep the original for labels.

If the grouping field contains nulls, decide how to handle them before dissolving. By default dissolve drops them silently, like a pandas groupby — the polygon and its area disappear from the output. Pass dropna=False to keep them as a group of their own, or drop them deliberately:

gdf = gdf.dropna(subset=["region"])
Cleaning a dissolve key with str.title() against stripping spaces and casefolding a separate key. clean the key without inventing new spellingsstr.title() on the label✕Côte d’Azur → Côte D’Azur✕a new spelling, a new group✕labels changed for good✕looks like cleaningstrip(), casefold into a key✓16 Länder stay 16✓key column for grouping✓original kept for labels✓dropna decided on purposeTitle-casing treats the apostrophe as the end of a word spatialworkflow.io
Clean the grouping key, not the label a reader will see.

Dissolve polygons by attribute

To merge polygons with the same attribute value, use dissolve(by="column_name").

dissolved = gdf.dissolve(by="region")
print(dissolved.geom_type.value_counts())
Polygon         12
MultiPolygon     4
Name: count, dtype: int64

Sixteen rows, one per Land — and four of them came out as MultiPolygon; Example 1 shows why.

Control how non-geometry fields are aggregated

If your input has other fields, GeoPandas can summarize them during the dissolve.

For example, to dissolve France's 101 départements into their 27 NUTS 2 regions, sum population, and keep one representative region_name value:

fr = gpd.read_file("data/fr_departements.geojson")
regions = fr.dissolve(
    by="region_id",
    aggfunc={
        "population": "sum",
        "region_name": "first"
    }
)
print(len(fr), "->", len(regions))
101 -> 27

Choose by the meaning of the column: counts and totals sum, a label identical within the group can take first, and a rate or density cannot be summed or plainly averaged — recompute it from the summed numerator and denominator.

Reset the index after dissolve

By default, the dissolve field becomes the index in the output.

print(dissolved.index[:3])
Index(['Baden-Württemberg', 'Bayern', 'Berlin'], dtype='str', name='region')

This is normal, but it is usually easier to inspect and export the data after resetting the index:

dissolved = dissolved.reset_index()
print(dissolved.columns.tolist())
['region', 'geometry', 'nuts_id', 'name']

Every other column survives with the default aggfunc="first": nuts_id and name now hold the code and name of whichever district came first in each Land. They look like data and describe nothing. Drop columns you did not aggregate on purpose, or name an aggregation for each.

Save the dissolved output

Write it as GeoPackage unless something downstream needs a shapefile, and see what the shapefile does to the French regions:

dissolved.to_file("output/dissolved_regions.gpkg", layer="regions", driver="GPKG")
regions.reset_index().to_file("output/fr_regions.shp")
print(gpd.read_file("output/fr_regions.shp").columns.tolist())
['region_id', 'population', 'region_nam', 'geometry']

That is the shapefile's ten-character field limit: region_name comes back as region_nam, with only a warning at write time.

German districts, French départements and Wight land use dissolved

The data used on this page

Three real layers, built by the block below into data/:

  • German districts — the 400 NUTS 3 regions from Eurostat's GISCO NUTS 2024 boundaries (1:20 million), with the name of each district's Land as region, saved as a shapefile.
  • French départements — the 101 French NUTS 3 regions with their NUTS 2 region and their population on 1 January 2024 from Eurostat table demo_r_pjanaggr3.
  • Isle of Wight land use — every OpenStreetMap polygon with a landuse tag, from the Geofabrik extract.
import io
import os

import geopandas as gpd
import pandas as pd
import requests

os.makedirs("data", exist_ok=True)
os.makedirs("output", exist_ok=True)
GISCO = ("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
         "NUTS_RG_20M_2024_4326_LEVL_{}.geojson")

if not os.path.exists("data/de_districts.shp"):
    nuts = {level: gpd.read_file(GISCO.format(level)) for level in (1, 2, 3)}
    names = {level: nuts[level].set_index("NUTS_ID")["NAME_LATN"] for level in (1, 2)}
    de = nuts[3][nuts[3]["CNTR_CODE"] == "DE"]
    gpd.GeoDataFrame({"nuts_id": de["NUTS_ID"], "name": de["NAME_LATN"],
                      "region": de["NUTS_ID"].str[:3].map(names[1])},
                     geometry=de.geometry.values, crs=4326).to_file("data/de_districts.shp")

    csv = requests.get("https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/data/"
                       "demo_r_pjanaggr3/A.NR.T.TOTAL.", timeout=180,
                       params={"format": "SDMX-CSV", "startPeriod": "2024", "endPeriod": "2024"},
                       headers={"User-Agent": "spatialworkflow.io guide"}).text
    population = pd.read_csv(io.StringIO(csv)).set_index("geo")["OBS_VALUE"]
    population.to_csv("data/eurostat_population_2024.csv")
    fr = nuts[3][nuts[3]["CNTR_CODE"] == "FR"]
    gpd.GeoDataFrame({"nuts_id": fr["NUTS_ID"], "name": fr["NAME_LATN"],
                      "region_id": fr["NUTS_ID"].str[:4],
                      "region_name": fr["NUTS_ID"].str[:4].map(names[2]),
                      "population": fr["NUTS_ID"].map(population)},
                     geometry=fr.geometry.values, crs=4326).to_file("data/fr_departements.geojson")

if not os.path.exists("data/iow_landuse.gpkg"):
    pbf = "isle-of-wight-260101.osm.pbf"
    if not os.path.exists(pbf):
        with requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
                          "isle-of-wight-260101.osm.pbf", stream=True, timeout=300) as r:
            with open(pbf, "wb") as f:
                for chunk in r.iter_content(1 << 20):
                    f.write(chunk)
    lu = gpd.read_file(pbf, layer="multipolygons", columns=["osm_id", "osm_way_id", "landuse"])
    lu[lu["landuse"].notna()].to_file("data/iow_landuse.gpkg")

for f in sorted(os.listdir("data")):
    print(f)
de_districts.cpg
de_districts.dbf
de_districts.prj
de_districts.shp
de_districts.shx
eurostat_population_2024.csv
fr_departements.geojson
iow_landuse.gpkg

Example 1: Dissolve polygons by a single attribute

This example reads the German districts and dissolves them by region, then looks at what the merge produced.

import geopandas as gpd
import shapely

gdf = gpd.read_file("data/de_districts.shp")

dissolved = gdf.dissolve(by="region").reset_index()
dissolved["parts"] = shapely.get_num_geometries(dissolved.geometry.values)
dissolved["holes"] = [sum(shapely.get_num_interior_rings(shapely.get_parts(g)))
                      for g in dissolved.geometry]
dissolved["km2"] = dissolved.to_crs("EPSG:3035").area / 1e6

print(dissolved.loc[(dissolved.parts > 1) | (dissolved.holes > 0),
                    ["region", "parts", "holes", "km2"]].round(0).to_string(index=False))
            region  parts  holes     km2
       Brandenburg      1      1 29465.0
            Bremen      2      0   404.0
           Hamburg      2      0   760.0
     Niedersachsen      2      1 47478.0
Schleswig-Holstein      6      0 15456.0

This creates one output feature for each unique region, and a quarter of them are not simple polygons. Bremen is two parts because the city of Bremerhaven, 50 km downriver, belongs to it; Schleswig-Holstein's parts are its islands, North Sea and Baltic, and Niedersachsen's second part is Borkum. Brandenburg has a hole where Berlin is, and Niedersachsen one where Bremen is — a dissolve keeps an enclave as a hole rather than filling it.

The five German Länder that a dissolve of the 400 NUTS 3 districts returns as multipart or holed polygons. 400 districts → 16 Länder: five are not one simple polygonpartsholeswhyBrandenburg11Berlin inside itBremen20Bremerhaven exclaveHamburg20Neuwerk islandNiedersachsen21Borkum; Bremen insideSchleswig-Holstein60islands incl. FehmarnGISCO NUTS 2024 at 1:20 million; the other 11 Länder dissolve to one polygon with no holes spatialworkflow.io
A dissolve keeps exclaves as extra parts and enclaves as holes — both are correct, and both break code that assumes one Polygon per row.

Example 2: Dissolve polygons and aggregate attribute values

This example dissolves the French départements by NUTS 2 region, sums their 1 January 2024 population, and checks the sums against Eurostat's own regional figures.

import geopandas as gpd
import pandas as pd

gdf = gpd.read_file("data/fr_departements.geojson")

dissolved = gdf.dissolve(
    by="region_id",
    aggfunc={
        "population": "sum",
        "region_name": "first"
    }
).reset_index()

eurostat = pd.read_csv("data/eurostat_population_2024.csv", index_col="geo")["OBS_VALUE"]
dissolved["eurostat"] = dissolved["region_id"].map(eurostat)
print(dissolved.sort_values("population", ascending=False)
      [["region_id", "region_name", "population"]].head(3).to_string(index=False))
print(f"{len(dissolved)} regions; {(dissolved.population != dissolved.eurostat).sum()} "
      f"differ from Eurostat's regional totals")
region_id                region_name  population
     FR10              Ile-de-France    12507542
     FRK2                Rhône-Alpes     6868920
     FRL0 Provence-Alpes-Côte d’Azur     5252362
27 regions; 0 differ from Eurostat's regional totals

This is a common pattern when building higher-level administrative boundaries from smaller polygons, and a sum is the safe case: the parts partition the whole, so the totals must add up exactly. They do. French NUTS 2 regions are the pre-2016 regions such as Rhône-Alpes, not today's 13 administrative régions — check which geography a code list means before trusting a name.

Example 3: Export the dissolved layer to a new file

This example dissolves OpenStreetMap land use on the Isle of Wight by class, repairs the inputs first, and writes the result to GeoJSON.

import geopandas as gpd
import pandas as pd

gdf = gpd.read_file("data/iow_landuse.gpkg").to_crs("EPSG:27700")
print(f"{len(gdf):,} polygons, {(~gdf.is_valid).sum()} invalid")

as_is = gdf.dissolve(by="landuse")
repaired = gdf.assign(geometry=gdf.make_valid()).dissolve(by="landuse")
print("invalid after dissolve:", (~as_is.is_valid).sum())
structure = gdf.assign(geometry=gdf.make_valid(method="structure")).dissolve(by="landuse")
gaps = pd.DataFrame({"linework_ha": (repaired.area - as_is.area) / 1e4,
                     "structure_ha": (structure.area - as_is.area) / 1e4})
print(gaps[gaps.abs().max(axis=1) > 0.01].round(2).to_string())

total = repaired.area.sum() / 1e4
union = repaired.union_all().area / 1e4
print(f"classes sum to {total:,.1f} ha; their union is {union:,.1f} ha")
r = repaired.reset_index()[["landuse", "geometry"]]
pairs = gpd.overlay(r, r.rename(columns={"landuse": "other"}), keep_geom_type=True)
pairs = pairs[pairs["landuse"] < pairs["other"]]
print((pairs.set_index(["landuse", "other"]).area / 1e4).nlargest(3).round(1).to_string())

repaired.reset_index().to_file("output/land_use_dissolved.geojson", driver="GeoJSON")
4,984 polygons, 10 invalid
invalid after dissolve: 0
Empty DataFrame
Columns: [linework_ha, structure_ha]
Index: []
classes sum to 29,963.7 ha; their union is 29,330.7 ha
landuse  other      
meadow   military       201.8
grass    residential     49.6
forest   grass           43.7

The dissolve does not fail on the invalid inputs: the union repairs them on the way, and the result is valid. How they were repaired is still a choice, so the example compares: on this extract both make_valid() modes, linework and structure, agree with the plain union to within 0.01 ha in every class, which is why the table of differences is empty. Nothing would have said so if they had not — the difference only shows if you compare.

The last line is a different problem. OSM land use polygons overlap — the largest overlap here is meadow inside a military area — so the classes sum to more than the land they cover. Dissolving by class merges polygons within a class; it does nothing about overlaps between classes. You can open the file directly in QGIS to see them.

Largest overlaps between OpenStreetMap land use classes on the Isle of Wight after dissolving by class. dissolving by class leaves overlaps between classesall overlaps633.0 hameadow ∩ military201.8 hagrass ∩ residential49.6 haforest ∩ grass43.7 ha0350 ha700 haClasses sum to 29,963.7 ha; the land they cover is 29,330.7 ha spatialworkflow.io
Summing dissolved class areas counts these hectares twice.

What a union gives back

A dissolve is a groupby whose aggregation for the geometry column is a union, and almost everything surprising about it follows from the union. A union removes the boundaries shared within a group and keeps everything else. Pieces of a group that do not touch stay separate parts, so Bremen comes back as two (Bremerhaven is 50 km downriver) and Schleswig-Holstein as six with its islands. Territory of another group surrounded by this one stays out of it, so Brandenburg gets a hole where Berlin is. Both are correct, and both break code that expects one Polygon per row.

What aggfunc does to the non-geometry columns during a dissolve. what happens to the other columnsaggfuncpopulationuse when"first" (default)value of one rowids and names"sum"total of the groupcounts, areas"mean"averagerates — beware"max" / "min"extreme valuethresholdsdict per columnmixedreal datasetsThe default keeps the first row's value — which is rarely what you want for numbersAveraging a rate across unequal polygons gives a number nobody can defend spatialworkflow.io
Pass a dictionary — different columns almost always need different treatment.

The union also repairs as it goes: ten invalid Isle of Wight polygons went in and none came out, without a message — and repairing them first with make_valid() gave the same class areas to 0.01 ha, so here the order made no difference; it is still worth the comparison. What a union cannot do is see across groups. Land-use classes that overlap each other still overlap after each class is dissolved, so their areas sum to 29,963.7 ha over 29,330.7 ha of ground.

If you want to merge all polygons into one feature instead of grouping by a field, use a constant field first:

import shapely

gdf = gpd.read_file("data/de_districts.shp")
gdf["group_all"] = 1
all_merged = gdf.dissolve(by="group_all").reset_index(drop=True)
outline = all_merged.geometry.iloc[0]
print(outline.geom_type, shapely.get_num_geometries(outline), "parts,",
      sum(shapely.get_num_interior_rings(shapely.get_parts(outline))), "holes")
MultiPolygon 8 parts, 0 holes

gdf.dissolve() with no by does the same in one step.

Holes, overlaps and the columns you did not name

  • Enclaves stay holes. If you need the outer outline of a group — a region including the city it surrounds — fill the interior rings afterwards, or dissolve the enclave in with it.
  • Overlaps between groups survive. Dissolving by class merges within a class only; sum class areas over a non-overlapping layer, or overlay the classes first.
  • Null keys vanish by default. dropna=False keeps them as a group of their own.
  • Unnamed columns default to first. Drop what you did not aggregate on purpose.
  • Measure in an equal-area CRS. The Länder and Wight areas above were computed in EPSG:3035 and EPSG:27700, not in the file's degrees.

FAQ

How do I dissolve polygons by attribute in GeoPandas?

Use dissolve(by="field_name") on a polygon GeoDataFrame.

dissolved = gdf.dissolve(by="region").reset_index()

This groups polygons by the chosen field and merges their geometries.

What is the difference between dissolve and unary union in GeoPandas?

dissolve() groups features by an attribute and can aggregate attributes. A unary union merges geometries without preserving grouped attribute structure. Use dissolve when you need one output feature per category.

Why does dissolve() make my grouping field become the index?

That is the default GeoPandas behavior. After dissolving, run:

dissolved = dissolved.reset_index()

This moves the grouping field back into a normal column.

How do I keep or summarize attribute columns after dissolving polygons?

Pass aggfunc with aggregation rules:

dissolved = gdf.dissolve(
    by="county",
    aggfunc={"population": "sum", "name": "first"}
).reset_index()

Use functions that match the meaning of each field.

How do I dissolve by more than one column at once?

Pass a list of column names to by, for example gdf.dissolve(by=["state", "land_use"]). GeoPandas groups on the unique combination of those fields and merges the geometries within each combination, producing a MultiIndex you can flatten with reset_index().

Why are tiny sliver gaps or overlaps left between polygons after dissolving?

Dissolve unions the geometries as they are, so boundaries that do not coincide exactly leave slivers — gaps become holes, overlaps vanish into the union. A topologically clean source leaves none: all 400 German districts dissolve into 8 parts (the mainland and islands) with no holes. For a messy source, repair with make_valid() and then snap to a common grid with set_precision() before dissolving; avoid buffer(0), which can silently drop parts of a polygon.

Does dissolve() keep my original CRS?

Yes. Dissolve does not reproject; the output carries the same CRS as the input. If you plan to measure the merged areas afterward, reproject to a projected CRS with to_crs() first, since area on a geographic CRS like EPSG:4326 is not in meters.

For each group, dissolve() effectively calls union_all() on that group's geometries to merge them, then attaches the aggregated attributes. If you only need a single merged geometry with no grouping or attributes, calling gdf.union_all() directly is simpler.