GIS & Geospatial Data (QGIS, GeoPandas, PostGIS) Module 5 – Python: Shapely and GeoPandas
Overlay, dissolve, clip and areal weighting
Combine polygon layers with overlay, merge them with dissolve, cut them with clip, move counts between zones by areal weighting, and clear slivers.
What you will learn
- Combine two polygon layers with intersection, union, identity and difference overlays
- Aggregate features by attribute with dissolve, choosing a rule for each column
- Clean overlay results by clipping to an outline and removing slivers with a precision grid or a minimum area
- Estimate counts for new zones by areal weighting, and state the assumption it makes
Before you start
What you’ll learn · Before you start
What you will learn
- Combine two polygon layers with intersection, union, identity and difference overlays
- Aggregate features by attribute with dissolve, choosing a rule for each column
- Clean overlay results by clipping to an outline and removing slivers with a precision grid or a minimum area
- Estimate counts for new zones by areal weighting, and state the assumption it makes
Before you start
On this page
What share of each ward lies within 1 km of a clinic? For W12 it is 82.9 %, for W10 nothing at all, and a buffer, a dissolve, an overlay and a division of areas get those numbers.
Here you meet overlay, dissolve, clip and areal weighting, four operations on polygon layers, and run them end to end on the town. Then you take each apart: the modes of overlay and the pieces nobody asked for, dissolve rules column by column, and how far a count moved by area can be from the truth.
Overlay, dissolve, clip and areal weighting each answer one question
The four operations answer four different questions. Overlay cuts two polygon layers against each other into new pieces, and each piece keeps “the properties from both” layers (GeoPandas, set operations with overlay). dissolve: Merging the features of a layer that share a value into one feature per value, with a rule for each attribute column, such as a sum for counts. merges the features that share a value, “all the geometries within a given group”, into one feature and aggregates their columns (GeoPandas, aggregation with dissolve). Clip cuts one layer to the outline of another and keeps only the first layer’s columns (GeoPandas, clip). areal weighting: Estimating a count for new zones from old ones: each piece of an overlay of the two zone systems gets the share of the old zone's count that matches its share of the old zone's area. It assumes the count is spread evenly. moves a count from one set of zones to another in proportion to the area they share; it is an estimate, not a measurement. Here they are on the town:
Overlay, dissolve, clip and areal weighting on the town
Text description of the diagram
Overlay: wards W02 and W03 sit side by side and clinic C2's 1 km ring lies across their shared edge. The overlay cuts the ring into two pieces, one in each ward, and each piece carries the columns of both layers: W02 with C2, and W03 with C2.
Dissolve: the four wards of the north row, W01 to W04, drawn with dashed inner edges, become one zone with a single outline. Their household counts are summed: 295 + 696 + 183 + 1,169 = 2,343.
Clip: households around C2 are dots; the 248 inside the ring are dark and kept, the others are pale and dropped. The kept households keep only their own columns.
Areal weighting: a block of 300 households is cut by a catchment's dashed edge into 60 % and 40 % of its area. If the households are spread evenly, 180 go to one catchment and 120 to the other.
| Operation | Question it answers | Columns of the result |
|---|---|---|
| Overlay | Which parts of A lie in which parts of B? | Both layers’ |
| Dissolve | What is each group as one shape? | The group’s, aggregated |
| Clip | What of A lies inside this outline? | A’s only |
| Areal weighting | How much of a count belongs to each new zone? | The new zones’ estimates |
An overlay’s how chooses which pieces to keep. Intersection keeps the pieces that lie in both layers, union every
piece of either, identity all of the first layer cut by the second, difference what of the first lies outside the
second, and symmetric difference what lies in exactly one.
An overlay of C2’s ring with the wards gives two pieces that know both their ward and their clinic. A clip of the households to the same ring keeps 248 households and nothing of the ring. Areal weighting assumes the count is spread evenly inside each source zone, so a block cut 60 % to 40 % gives 60 % of its households to one side.
Checkpoint
Check: What overlay, dissolve, clip and areal weighting return
Coverage per ward takes four steps
The opening’s question needs the operations in order. Follow the pipeline before running it:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Eight rings. Each clinic gets a 1 km buffer with 16 segments a quarter. The rings overlap in three places, so their areas add up to more than the ground they cover.
- One shape. dissolve with no column merges all eight into one MultiPolygon of 24.695 km². An overlay with the separate rings would have cut every overlap twice.
- Cut by the wards. overlay with how="intersection" cuts the shape at every ward edge and keeps the pieces inside wards: 11 pieces, each with its ward column.
- Shares of area. Each piece's area divided by its ward's 4 km² is the share within 1 km of a clinic: 82.9 % of W12, 75.5 % of W09, 8.8 % of W05.
- The ward with no piece. W10 has no piece at all, so it is missing until the shares are reindexed on every ward with 0. A map without that step would leave W10 blank instead of showing 0 %.
- Households reached. clip keeps the households inside the same shape, 2,971 of 5,000, with only their own columns. The town as a whole is 49.9 % covered.
The program does exactly that, then clips the households to the same shape:
"""The track's synthetic town in metres of UTM zone 43N (EPSG:32643): 12 square wards of 2 km, 5,000 households
from a seeded generator and 8 clinics. Every lesson of the track asks its questions about this town."""
import random
E0, N0 = 500_000, 2_000_000 # the town's south-west corner: easting and northing in metres
WARD = 2_000 # each ward is a 2 km square
COLS, ROWS = 4, 3 # 4 wards from west to east, 3 from south to north
# Metres east and north of the south-west corner. C2 stands exactly on the edge of W02 and W03, on purpose.
CLINICS = {
"C1": (1_500, 5_200), "C2": (4_000, 5_000), "C3": (6_500, 4_200), "C4": (2_700, 3_100),
"C5": (5_200, 2_800), "C6": (900, 1_100), "C7": (5_600, 1_200), "C8": (7_400, 900),
}
def ward_bounds():
"""W01 to W12, north-west first, each as (min x, min y, max x, max y)."""
out = {}
for row in range(ROWS):
for col in range(COLS):
x0, y0 = E0 + col * WARD, N0 + (ROWS - 1 - row) * WARD
out[f"W{row * COLS + col + 1:02d}"] = (x0, y0, x0 + WARD, y0 + WARD)
return out
def households(seed=42, n=5_000):
"""(id, x, y) of n households, most of them around three neighbourhood centres."""
rng = random.Random(seed)
centres = [(2_600, 4_300, 700), (5_600, 1_500, 900), (6_900, 4_700, 500)]
homes = []
while len(homes) < n:
if rng.random() < 0.7:
cx, cy, spread = rng.choice(centres)
# The sum of three random numbers clusters around its middle, like a bell curve.
x = cx + spread * 2 * (rng.random() + rng.random() + rng.random() - 1.5)
y = cy + spread * 2 * (rng.random() + rng.random() + rng.random() - 1.5)
else:
x, y = rng.uniform(0, COLS * WARD), rng.uniform(0, ROWS * WARD)
if 0 < x < COLS * WARD and 0 < y < ROWS * WARD:
homes.append((f"H{len(homes) + 1:04d}", round(E0 + x, 1), round(N0 + y, 1)))
return homes
def clinics():
"""(id, x, y) of the 8 clinics."""
return [(k, E0 + x, N0 + y) for k, (x, y) in CLINICS.items()] """The town's layers in EPSG:32643 for overlays: wards, households, clinics and their 1 km buffers, 24 census blocks
(each ward split in two at a seeded line), the clinics' catchments (the area nearer to each clinic than to any other),
and two planning zones whose shared edge was digitised a few millimetres off the ward boundary."""
import random
import geopandas as gpd
import shapely
from town import COLS, E0, N0, ROWS, WARD, clinics, households, ward_bounds
CRS = "EPSG:32643"
def wards():
bounds = ward_bounds()
return gpd.GeoDataFrame({"ward": list(bounds)}, geometry=[shapely.box(*b) for b in bounds.values()], crs=CRS)
def homes():
rows = households()
return gpd.GeoDataFrame({"household": [h for h, _, _ in rows]}, geometry=shapely.points([(x, y) for _, x, y in rows]), crs=CRS)
def clinic_points():
rows = clinics()
return gpd.GeoDataFrame({"clinic": [c for c, _, _ in rows]}, geometry=shapely.points([(x, y) for _, x, y in rows]), crs=CRS)
def rings(distance=1_000):
c = clinic_points()
return c.set_geometry(c.buffer(distance, quad_segs=16))
def blocks(seed=7):
"""24 census blocks: each ward cut in two by a north-south line at 500 to 1,500 m from its west edge."""
rng = random.Random(seed)
rows = []
for ward, (x0, y0, x1, y1) in ward_bounds().items():
cut = x0 + rng.randrange(500, 1_501, 100)
rows += [(ward + "a", ward, shapely.box(x0, y0, cut, y1)), (ward + "b", ward, shapely.box(cut, y0, x1, y1))]
b = gpd.GeoDataFrame({"block": [r[0] for r in rows], "ward": [r[1] for r in rows]}, geometry=[r[2] for r in rows], crs=CRS)
inside = gpd.sjoin(homes(), b, predicate="within").groupby("block").size()
b["households"] = b["block"].map(inside).fillna(0).astype(int)
return b
def catchments():
"""The part of the town nearer to each clinic than to any other (Voronoi cells, cut to the town)."""
c = clinic_points()
town = shapely.box(E0, N0, E0 + COLS * WARD, N0 + ROWS * WARD)
cells = shapely.voronoi_polygons(shapely.MultiPoint(list(c.geometry)), extend_to=town)
rows = []
for cell in cells.geoms:
name = c.loc[c.intersects(cell), "clinic"].iloc[0]
rows.append((name, cell.intersection(town)))
out = gpd.GeoDataFrame({"clinic": [r[0] for r in rows]}, geometry=[r[1] for r in rows], crs=CRS)
return out.sort_values("clinic").reset_index(drop=True)
def zones():
"""Two planning zones split along y = 2,004,000, but digitised with the edge a few millimetres off."""
wobble = [0.003, -0.002, 0.004, -0.001, 0.002]
edge = [(E0 + i * WARD, N0 + 2 * WARD + d) for i, d in enumerate(wobble)]
north = shapely.Polygon([(E0, N0 + ROWS * WARD), *edge, (E0 + COLS * WARD, N0 + ROWS * WARD)])
south = shapely.Polygon([(E0, N0), (E0 + COLS * WARD, N0), *edge[::-1]])
return gpd.GeoDataFrame({"zone": ["North", "South"]}, geometry=[north, south], crs=CRS) """What share of each ward lies within 1 km of a clinic? Buffer the clinics, dissolve the buffers into one shape,
overlay it with the wards, and divide each piece's area by its ward's; compare W12 with the rings overlaid without the
dissolve; then clip the households to the same shape."""
import geopandas as gpd
from layers import homes, rings, wards
w = wards()
cover = rings(1_000).dissolve() # 8 buffers become one MultiPolygon, overlaps counted once
print(f"dissolved buffers: {len(cover)} row, a {cover.geom_type.iloc[0]} of {cover.area.iloc[0] / 1e6:.3f} km²")
# Each ward cut to the covered area. keep_geom_type=True keeps polygons only: where a buffer meets a ward
# at a single corner, the intersection there is a point, and an area table has no use for it.
pieces = w.overlay(cover[["geometry"]], how="intersection", keep_geom_type=True)
pieces["share"] = pieces.area / (2_000 * 2_000)
share = pieces.set_index("ward")["share"].reindex(w["ward"], fill_value=0)
print(f"{len(pieces)} pieces; share of each ward within 1 km of a clinic:")
for (ward, s) in share.items():
print(f" {ward} {s:6.1%} {'#' * round(s * 20)}")
print(f"the town: {pieces.area.sum() / w.area.sum():.1%} of {w.area.sum() / 1e6:.0f} km²")
# The same overlay with the 8 rings as they are: ground where two rings overlap becomes one piece per ring.
loose = w.overlay(rings(1_000), how="intersection", keep_geom_type=True)
w12 = loose.loc[loose["ward"] == "W12"].area.sum() / (2_000 * 2_000)
print(f"W12 from the 8 rings without the dissolve: {w12:.1%}")
reached = gpd.clip(homes(), cover)
print(f"\nclip(households, cover): {len(reached):,} of 5,000 households, with their own columns {reached.columns.tolist()}")
c2_ring = rings(1_000).query("clinic == 'C2'")
print(f"clip(households, C2's ring alone): {len(gpd.clip(homes(), c2_ring))} households") Output
dissolved buffers: 1 row, a MultiPolygon of 24.695 km² 11 pieces; share of each ward within 1 km of a clinic: W01 59.1% ############ W02 55.9% ########### W03 49.3% ########## W04 39.0% ######## W05 8.8% ## W06 69.6% ############## W07 75.1% ############### W08 28.4% ###### W09 75.5% ############### W10 0.0% W11 55.6% ########### W12 82.9% ################# the town: 49.9% of 48 km² W12 from the 8 rings without the dissolve: 85.3% clip(households, cover): 2,971 of 5,000 households, with their own columns ['household', 'geometry'] clip(households, C2's ring alone): 248 households
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 coverage.py
Half the town, 49.9 %, lies within 1 km of a clinic, but the wards range from 82.9 % for W12 to 0 % for W10, which
has no piece at all and returns only through reindex. The clip found 2,971 households inside, the count of the
Shapely lesson’s union.
Overlaying the buffers before dissolving them
Overlaid as eight separate rings, the buffers cut each overlap twice, and the pieces of W12 add up to 85.3 % of the ward instead of 82.9 %. Dissolve the buffers first, so that every place is counted once.
Checkpoint
Check: Coverage per ward, step by step
Overlays in depth: five modes, stray points and slivers, measured Pro: opens with a Pro, Premium or Ultimate pass
Overlay has five modes, and each answers a different question about the same two layers. It can also hand back pieces you did not ask for, points where shapes touch and slivers where edges were drawn twice; all of them are measured here on the wards and the clinics’ buffers.
In this part:
- Five overlay modes on the same two layers
- Stray points where a ring touches a corner of two wards
- Slivers from an edge digitised twice, and two ways to remove them
2 runnable examples (Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Dissolve rules and areal weighting, with the errors measured Pro: opens with a Pro, Premium or Ultimate pass
Dissolving and reallocating move numbers, and every column needs its own rule. Here 24 census blocks are dissolved back into their wards and reallocated to the clinics’ catchments, and each result is compared with the households that really live there.
In this part:
- Each column needs its own aggregation rule
- Areal weighting assumes an even spread, and the errors show where it fails
- When to trust a count moved by area
2 runnable examples (Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: move counts by area, and measure the error
Write the two functions of a reallocation, the estimate by area and its error against true counts, and run their sample tests:
Exercise · Medium · Python
Move counts between zones by area, and measure the error
Write two functions in weights.py. reallocate(counts, areas, pieces) estimates a count for each target zone by area weighting: counts and areas give each source zone's count and area, and pieces is a list of (source, target, area), the pieces an overlay of the two zone systems returns. Each piece receives the source's count times the piece's share of the source's area. Return {target: estimate}, rounded to 1 decimal. errors(estimates, truth) returns each target's error as a percentage of its true count, (estimate − truth) ÷ truth × 100, rounded to 1 decimal.
The sample tests use the lesson's example of a 300-household block cut 60 % to 40 %, and two blocks of the town.
Starter code · weights.py
def reallocate(counts, areas, pieces):
"""{target: estimated count}: each piece of an overlay gets its source's count in proportion to its area."""
# Replace this line: add counts[source] * area / areas[source] to each target, then round to 1 decimal.
return {}
def errors(estimates, truth):
"""{target: error in % of the true count}, rounded to 1 decimal."""
# Replace this line: (estimate - truth) / truth * 100 for every target.
return {} The sample tests · test_weights.py
from weights import errors, reallocate
def test_one_block_cut_sixty_forty():
"""splits a block of 300 households cut 60 % to 40 % into 180 and 120"""
got = reallocate({"B1": 300}, {"B1": 1.0}, [("B1", "east", 0.6), ("B1", "west", 0.4)])
assert got == {"east": 180.0, "west": 120.0}
def test_two_blocks_into_two_zones():
"""adds the pieces of two blocks per zone and keeps the total"""
counts = {"W06a": 296, "W06b": 120}
areas = {"W06a": 2.8, "W06b": 1.2}
pieces = [("W06a", "C4", 2.1), ("W06a", "C2", 0.7), ("W06b", "C4", 0.3), ("W06b", "C5", 0.9)]
got = reallocate(counts, areas, pieces)
assert got == {"C4": 252.0, "C2": 74.0, "C5": 90.0}
assert round(sum(got.values()), 6) == 416
def test_a_source_outside_every_target():
"""gives nothing from a block that no target zone overlaps"""
assert reallocate({"A": 50, "B": 80}, {"A": 2.0, "B": 2.0}, [("A", "Z", 1.0)]) == {"Z": 25.0}
def test_errors_in_percent():
"""measures the error of each estimate against the true count"""
assert errors({"C1": 601.0, "C8": 324.0}, {"C1": 572, "C8": 249}) == {"C1": 5.1, "C8": 30.1}
assert errors({"C5": 454.0}, {"C5": 454}) == {"C5": 0.0} A hint
Loop over the pieces and add counts[source] * area / areas[source] to the target's running total in a dict; round at the end. For the errors, loop over the targets of truth.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Practice that adapts to you
Practise this lesson
6 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What is the difference between clip and an intersection overlay?
Both keep only the parts of a layer that fall inside another. Clip treats the second layer as an outline and returns the first layer’s features cut to it, with their own columns only. An intersection overlay treats both layers as data: a ring cut by two wards becomes two pieces that each know their ward and their clinic. Clip a layer to a study area; overlay when each piece must say where it came from.
Three longer questions continue in the module’s bank, each with what it tests and how to start:
-
Fresher to mid
Census blocks with households and a density column are dissolved into wards with aggfunc={"households": "sum", "density": "mean"}. A ward of 4 km² holds 1,169 households, yet its density comes out well below what those two numbers give. What is wrong, what is the right density, and how do you set up the dissolve?
-
Mid to senior
A health department has households per census block and needs households per clinic catchment, whose boundaries cross the blocks. Describe how you would estimate them, how wrong the estimates can be, and how you would report them honestly.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which question does each of overlay, dissolve, clip and areal weighting answer?
Overlay: which parts of A lie in which parts of B, keeping both layers' columns. Dissolve: what each group is as one shape, with its columns aggregated. Clip: what of A lies inside an outline, with A's columns only. Areal weighting: how much of a count belongs to each new zone, as an estimate.
-
Which pieces does each overlay mode keep?
Intersection keeps the pieces in both layers; union every piece of either; identity all of the first layer, cut by the second; difference what of the first lies outside the second; symmetric difference what lies in exactly one.
-
What does areal weighting assume about a count?
That it is spread evenly inside each source zone, so a block cut 60 % to 40 % hands 60 % of its households to one side. The result is an estimate, not a measurement.
-
Which steps answer "what share of each ward lies within 1 km of a clinic"?
Buffer the clinics, dissolve the buffers into one shape, overlay it with the wards, work out each piece's share of its ward's area, and reindex so that a ward with no piece, such as W10, shows 0. W12 came out at 82.9 % and the whole town at 49.9 %.
-
Why dissolve the buffers before overlaying them with the wards?
As separate rings, every overlap is cut out and counted twice: W12's pieces added up to 85.3 % of the ward instead of 82.9 %.
-
How does clip differ from an intersection overlay?
Clip treats the second layer as an outline and returns the first layer's features cut to it, with their own columns only. An intersection overlay treats both layers as data, so each piece knows its place in both, such as its ward and its clinic.
Key takeaways
- Overlay cuts two layers into pieces that keep both layers’ columns, dissolve merges features by a value, clip cuts a layer to an outline and keeps its own columns, and areal weighting moves counts by shared area.
- Coverage per ward is a buffer, a dissolve, an overlay and a division of areas, with every ward reindexed so that a ward with no piece shows 0.
- Dissolve buffers before an overlay, or each overlap is counted twice.
Terms in this lesson
- areal weighting
- Estimating a count for new zones from old ones: each piece of an overlay of the two zone systems gets the share of the old zone's count that matches its share of the old zone's area. It assumes the count is spread evenly.
- dissolve
- Merging the features of a layer that share a value into one feature per value, with a rule for each attribute column, such as a sum for counts.
References
- GeoPandas user guide: set operations with overlay (GeoPandas developers)
- GeoPandas: GeoDataFrame.overlay (GeoPandas developers)
- GeoPandas user guide: aggregation with dissolve (GeoPandas developers)
- GeoPandas: GeoDataFrame.dissolve (GeoPandas developers)
- GeoPandas: clip (GeoPandas developers)
- GeoPandas changelog (GeoPandas developers)
- Shapely: set_precision (Shapely contributors)
- Shapely: voronoi_polygons (Shapely contributors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress