Skip to content
← Back to Python for GIS: scripting, GDAL and spatial statistics
Coding for GIS · Part 11 of 22Beginner· 12 min readSources

GeoPandas Spatial Analysis: Spatial Join, Buffer, Overlay and Dissolve

Count points in polygons, find what lies near a road, measure how much of each district is flooded and merge districts into zones, using a small invented dataset you can run anywhere.

Part 10 made points and measured them. This part uses the operations that make GIS different from a spreadsheet: answers that depend on where things are. They map to the QGIS tools Join Attributes by Location, Buffer, Intersection and Dissolve.

The data

To keep the example runnable anywhere, the layers are built in code and are invented. They sit in EPSG:32643 (UTM zone 43N, metres) so every measurement is in metres. With real data you would replace the construction lines with gpd.read_file("districts.gpkg") and the rest would stay the same.

# Step 1: bring in the tools we need
import geopandas as gpd                      # maps and tables with geometry
import pandas as pd                          # ordinary tables
from shapely.geometry import box, Point, LineString   # shapes: rectangle, point, line

# Step 2: two settings used below
CRS = "EPSG:32643"                           # a coordinate system measured in metres (UTM zone 43N)
X0, Y0, SIZE = 700000, 1400000, 40000        # lower left corner of the grid, and each square's side in metres

# Step 3: build nine square districts in a 3 x 3 grid
# We make three empty lists, then add one district at a time.
cells, names, zones = [], [], []
for row in range(3):                         # rows 0, 1, 2 (bottom to top)
    for col in range(3):                     # columns 0, 1, 2 (left to right)
        # box(left, bottom, right, top) draws one square
        cells.append(box(X0 + col * SIZE, Y0 + row * SIZE,
                         X0 + (col + 1) * SIZE, Y0 + (row + 1) * SIZE))
        names.append(f"D{row * 3 + col + 1}")        # D1 ... D9
        zones.append(["South", "Central", "North"][row])   # row 0 = South, 1 = Central, 2 = North

# Step 4: put the names, zones and shapes into one GeoDataFrame (a table with a map column)
districts = gpd.GeoDataFrame({"district": names, "zone": zones}, geometry=cells, crs=CRS)

# Step 5: population lives in a separate plain table, as it often does in real life
pop = pd.DataFrame({
    "district": names,
    "population": [210000, 95000, 140000, 320000, 180000, 60000, 75000, 130000, 50000],
})

# Step 6: nine hospitals as (x, y) points in metres
hospital_xy = [(715000, 1410000), (725000, 1415000), (770000, 1405000), (730000, 1445000),
               (790000, 1450000), (750000, 1470000), (705000, 1485000), (765000, 1490000), (795000, 1515000)]
hospitals = gpd.GeoDataFrame(
    {"hospital": [f"H{i + 1}" for i in range(len(hospital_xy))]},   # H1 ... H9
    geometry=[Point(xy) for xy in hospital_xy],                    # turn each pair into a Point
    crs=CRS,
)

# Step 7: one main road (a line) and one flood zone along a river (a rectangle)
road = gpd.GeoDataFrame({"name": ["Main road"]},
                        geometry=[LineString([(700000, 1450000), (820000, 1440000)])], crs=CRS)
flood = gpd.GeoDataFrame({"name": ["River flood"]},
                         geometry=[box(735000, 1400000, 765000, 1470000)], crs=CRS)

# Check: 9 districts, 9 hospitals, and the EPSG code of the coordinate system
print(len(districts), len(hospitals), districts.crs.to_epsg())
9 9 32643

All four layers share one CRS. That is a rule for every operation here: layers must be in the same CRS, or the results are wrong.

1. Attribute join

First attach the population table to the district polygons by the key district. A GeoDataFrame merges just like a DataFrame (Part 8) and stays a GeoDataFrame.

# Step 1: attach population to each district by matching the "district" column
districts = districts.merge(pop, on="district", how="left")

# Step 2: area of each square in km2 (the CRS is in metres, so area is in m2; divide by 1,000,000)
districts["area_km2"] = (districts.geometry.area / 1_000_000).round(0)

# Step 3: people per km2
districts["density"] = (districts["population"] / districts["area_km2"]).round(1)

# Look at the result
print(districts[["district", "zone", "population", "area_km2", "density"]])
print("unmatched:", districts["population"].isna().sum())     # 0 means every district found its population
  district     zone  population  area_km2  density
0       D1    South      210000    1600.0    131.2
1       D2    South       95000    1600.0     59.4
2       D3    South      140000    1600.0     87.5
3       D4  Central      320000    1600.0    200.0
4       D5  Central      180000    1600.0    112.5
5       D6  Central       60000    1600.0     37.5
6       D7    North       75000    1600.0     46.9
7       D8    North      130000    1600.0     81.2
8       D9    North       50000    1600.0     31.2
unmatched: 0

The last line checks that every district found its population. Always count unmatched keys after a join.

2. Spatial join: points in polygons

A spatial join matches rows by geometry instead of by a key column. Here each hospital receives the attributes of the district it lies in.

# Which district is each hospital inside?
# sjoin = spatial join: it matches rows by where they are on the map, not by a shared column.
# how="left" keeps every hospital; predicate="within" means "the point lies inside the polygon".
located = gpd.sjoin(hospitals, districts[["district", "geometry"]], how="left", predicate="within")
print(located[["hospital", "district"]])
  hospital district
0       H1       D1
1       H2       D1
2       H3       D2
3       H4       D4
4       H5       D6
5       H6       D5
6       H7       D7
7       H8       D8
8       H9       D9

predicate="within" keeps pairs where the hospital point is inside the district. Other predicates are intersects, contains, touches and crosses. A point that falls on a shared border can match two polygons and appear twice, so check the row count (here it stays at 9).

Count hospitals per district with the groupby tool from Part 8, and add the result to the district table:

# Step 1: count hospitals per district (groupby gathers the rows of each district, size counts them)
counts = located.groupby("district").size().rename("hospitals")

# Step 2: add the counts to the districts table
districts = districts.merge(counts, on="district", how="left")

# Step 3: districts with no hospital got an empty value; make that 0 and a whole number
districts["hospitals"] = districts["hospitals"].fillna(0).astype(int)

# Step 4: hospitals per 100,000 people
districts["per_100k"] = (districts["hospitals"] / districts["population"] * 100000).round(2)

print(districts[["district", "population", "hospitals", "per_100k"]])
  district  population  hospitals  per_100k
0       D1      210000          2      0.95
1       D2       95000          1      1.05
2       D3      140000          0      0.00
3       D4      320000          1      0.31
4       D5      180000          1      0.56
5       D6       60000          1      1.67
6       D7       75000          1      1.33
7       D8      130000          1      0.77
8       D9       50000          1      2.00

A district with no hospital does not appear in counts, so the merge gives it NaN, and fillna(0) turns that into a true zero. Hospitals per 100,000 people is a fairer comparison than the raw count, because districts differ in size.

3. Buffer: what is near the road?

A buffer is the area within a distance of a feature. The distance is in the CRS units, so the layer must be projected. This is why EPSG:4326 data must be reprojected first (Part 10).

# Step 1: draw a 5 km zone on each side of the road (a "buffer")
road_buffer = road.copy()                    # copy, so the original road stays a line
road_buffer["geometry"] = road.buffer(5000)  # 5000 metres = 5 km

# Step 2: area of that zone in km2
print(round(road_buffer.geometry.area.iloc[0] / 1_000_000, 1), "km2")

# Step 3: keep only the hospitals that fall inside the buffer
# how="inner" drops hospitals with no match, so only the close ones remain
near_road = gpd.sjoin(hospitals, road_buffer[["geometry"]], how="inner", predicate="within")
print(sorted(near_road["hospital"]))
1282.6 km2
['H4']

The road is about 120 km long, so a 5 km buffer on each side covers about 1,200 km2, plus the rounded ends. The join keeps the hospitals inside it. Compare distances too: hospitals.distance(road.geometry.iloc[0]) gives each hospital's distance to the road in metres.

4. Overlay: intersect two polygon layers

overlay combines the geometry of two layers. intersection keeps only the parts that lie in both, and splits polygons where they meet.

# overlay with "intersection" keeps only the parts of each district that overlap the flood zone
flooded = gpd.overlay(districts[["district", "geometry"]], flood[["geometry"]], how="intersection")

# area of each flooded piece, in km2
flooded["flooded_km2"] = (flooded.geometry.area / 1_000_000).round(1)
print(flooded[["district", "flooded_km2"]])
  district  flooded_km2
0       D1        200.0
1       D2       1000.0
2       D4        150.0
3       D5        750.0

Each row is a piece of a district that lies inside the flood zone. A district that the flood does not touch has no row. Join the pieces back to the districts to get a flooded percentage:

# Step 1: add the flooded area to the districts table (districts the flood misses get an empty value)
districts = districts.merge(flooded[["district", "flooded_km2"]], on="district", how="left")
districts["flooded_km2"] = districts["flooded_km2"].fillna(0)      # empty -> 0

# Step 2: what share of each district is flooded?
districts["flooded_pct"] = (districts["flooded_km2"] / districts["area_km2"] * 100).round(1)

# Step 3: turn the percentage into a label.
# pd.cut sorts numbers into bins: -1 to 0 is "none", 0 to 25 is "low", 25 to 50 is "medium", 50 to 100 is "high".
districts["risk"] = pd.cut(
    districts["flooded_pct"],
    bins=[-1, 0, 25, 50, 100],
    labels=["none", "low", "medium", "high"],
)
print(districts[["district", "flooded_km2", "flooded_pct", "risk"]])
  district  flooded_km2  flooded_pct    risk
0       D1        200.0         12.5     low
1       D2       1000.0         62.5    high
2       D3          0.0          0.0    none
3       D4        150.0          9.4     low
4       D5        750.0         46.9  medium
5       D6          0.0          0.0    none
6       D7          0.0          0.0    none
7       D8          0.0          0.0    none
8       D9          0.0          0.0    none

Other how options are union (everything from both), difference (the first layer minus the second, so the order matters) and symmetric_difference. difference of districts and flood gives the dry part of each district.

# "difference" keeps the parts of each district that are NOT in the flood zone
dry = gpd.overlay(districts[["district", "geometry"]], flood[["geometry"]], how="difference")

# dry area plus flooded area should add up to the total
print(round(dry.geometry.area.sum() / 1_000_000), "km2 dry,",
      round(districts.geometry.area.sum() / 1_000_000), "km2 total")
12300 km2 dry, 14400 km2 total

Swapping the two layers would instead give the part of the flood zone outside every district, which here is nothing. The layer on the left is the one that is cut.

5. Dissolve: merge polygons by attribute

dissolve merges polygons that share a value and can summarise their attributes in the same step.

# dissolve merges neighbouring shapes that share a value (here the zone) into one shape
# aggfunc says how to combine the other columns: "sum" adds them up
zones = districts.dissolve(
    by="zone",
    aggfunc={"population": "sum", "hospitals": "sum", "area_km2": "sum"},
).reset_index()                              # make "zone" an ordinary column again

print(zones[["zone", "population", "hospitals", "area_km2"]])
print(len(districts), "districts ->", len(zones), "zones")      # 9 districts become 3 zones
      zone  population  hospitals  area_km2
0  Central      560000          3    4800.0
1    North      255000          3    4800.0
2    South      445000          3    4800.0
9 districts -> 3 zones

Nine district polygons become three zone polygons, and the numbers are added up. This is the Python form of the QGIS Dissolve tool plus Statistics by Categories. The dissolved zones have no internal borders.

6. Rank the districts

Combine the results in one table, the way a final analysis does.

# Sort so the districts with the fewest hospitals per 100,000 people come first, then keep the top 3
priority = districts.sort_values("per_100k").head(3)[["district", "population", "hospitals", "per_100k", "risk"]]
print(priority)
  district  population  hospitals  per_100k    risk
2       D3      140000          0      0.00    none
3       D4      320000          1      0.31     low
4       D5      180000          1      0.56  medium

This keeps things simple: the three districts with the fewest hospitals per 100,000 people. A real study would use weights for several factors and test how the ranking changes. Do not read this invented dataset as a finding about any real place.

7. Map the results

import matplotlib.pyplot as plt              # the drawing library GeoPandas uses

# One figure with three maps side by side. axes[0], axes[1], axes[2] are the three panels.
fig, axes = plt.subplots(1, 3, figsize=(15, 5))

# Map 1: districts coloured by hospital count, hospitals as red dots on top
districts.plot(ax=axes[0], column="hospitals", cmap="Blues", edgecolor="grey", legend=True)
hospitals.plot(ax=axes[0], color="red", markersize=25)
axes[0].set_title("Hospitals per district")

# Map 2: grey districts, then the flood zone, flooded parts, road buffer and road, drawn in that order
districts.plot(ax=axes[1], color="#eeeeee", edgecolor="grey")
flood.plot(ax=axes[1], color="#4a90d9", alpha=0.5)                          # alpha = see-through
flooded.plot(ax=axes[1], facecolor="none", edgecolor="navy")
road_buffer.plot(ax=axes[1], facecolor="none", edgecolor="orange", linestyle="--")
road.plot(ax=axes[1], color="darkorange")
axes[1].set_title("Flood zone and road buffer")

# Map 3: districts coloured by flooded share, with a thick outline around each zone
districts.plot(ax=axes[2], column="flooded_pct", cmap="Reds", edgecolor="grey", legend=True)
zones.boundary.plot(ax=axes[2], color="black", linewidth=2)
axes[2].set_title("Flooded share (%), zone outlines")

# Hide the axis numbers and frame, then save the picture
for ax in axes:
    ax.set_axis_off()
fig.savefig("analysis_maps.png", dpi=130, bbox_inches="tight")
print("saved")
saved
Three maps of a nine district grid: hospitals per district, the flood zone with a road buffer, and the flooded share of each district with zone outlines
Figure 11.1. Hospitals per district, the flood zone and road buffer, and the flooded share of each district

Layers are drawn in the order of the calls, using ax= to share one map. set_axis_off() hides the axes, which are not needed for a schematic map. boundary turns polygons into their outlines.

Run this part as a notebook

The code on this page is also a Jupyter notebook. It has the theory in text cells, then the code, then guided practice, an independent assignment and hidden solutions, so you can try each task before you look. Download the Part 11 notebook, open it in JupyterLab and run the cells from top to bottom. Part 1 shows how to install and start Jupyter.

Practice

  1. Add a tenth hospital in a district that has none and rerun the join. How does per_100k change?
  2. Change the road buffer to 10 km. How many hospitals are within it now?
  3. Make a second flood zone and use how="union" on the two zones before the overlay.
  4. Dissolve the districts by a column you create yourself, such as east and west.

Common mistakes

  • Combining layers with different CRSs. Check with a.crs == b.crs.
  • Buffering a layer in degrees.
  • Forgetting that a spatial join can duplicate rows when a feature matches several polygons.
  • Swapping the layers in difference and cutting the wrong one.
  • Calculating an area before overlay and not after, so the flooded area is the whole district.
  • Forgetting fillna(0) for districts with no match, so they drop out of charts and averages.

Key takeaways

Next: GDAL rasters with Python.