Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

GIS & Geospatial Data (QGIS, GeoPandas, PostGIS)  Module 5 – Python: Shapely and GeoPandas

Shapely 2: geometry operations in Python

Test thousands of points in one call, buffer in the right units, combine shapes with unions and differences, and repair input with make_valid and grids.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, shapely 2.1.2, geopandas 1.2.0, pyproj 3.8.0 and numpy 2.5.3
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Create geometries as NumPy arrays and test 5,000 points against 12 polygons in one vectorised call
  • Predict the sides, the area and the units of a buffer from its distance and quad_segs
  • Combine buffers with union, difference and symmetric difference and read what their areas mean
  • Repair invalid input with make_valid and join edges that nearly meet with a precision grid
  • Explain why a GEOS version can change a result, and compare geometries by shape instead of text

Before you start

What you’ll learn · Before you start

What you will learn

  • Create geometries as NumPy arrays and test 5,000 points against 12 polygons in one vectorised call
  • Predict the sides, the area and the units of a buffer from its distance and quad_segs
  • Combine buffers with union, difference and symmetric difference and read what their areas mean
  • Repair invalid input with make_valid and join edges that nearly meet with a precision grid
  • Explain why a GEOS version can change a result, and compare geometries by shape instead of text

Before you start

On this page

Which of 5,000 households lie within 1 km of one of 8 clinics? Asked one household and one clinic at a time, that is 40,000 distance questions passed one by one from Python to the geometry engine; Shapely 2 takes whole NumPy arrays of geometries and loops over them in compiled code, which is also how GeoPandas works underneath.

Here you build the track’s town as arrays and count the households of 12 wards with one call, predict the sides and the units of a buffer, combine buffers into unions and differences whose areas mean something, repair the input that breaks those operations, and keep results comparable when the engine underneath changes version.

One call tests 5,000 points against 12 wards

Shapely is a Python package “for manipulation and analysis of planar geometric objects”, and its functions are “NumPy ufuncs for operations using arrays of geometries” whose loops “are implemented in C” (Shapely documentation). The geometry work itself is done by GEOS (Geometry Engine, Open Source): The C and C++ library that computes predicates, buffers, unions and other overlays for Shapely, PostGIS, QGIS and GDAL. Each installation bundles or links its own version, which can change the exact vertices of a result., the engine that PostGIS and QGIS also use. GeoPandas adds tables on top: its documentation says plainly that “geometric operations are performed by shapely” (GeoPandas).

That changes how you write a test. shapely.points(xy) turns a 5,000 × 2 array of coordinates into an array of 5,000 points in one call, and shapely.contains(wards[:, np.newaxis], points[np.newaxis, :]) compares 12 wards with every point at once, by NumPy’s broadcasting, into a table of 12 rows and 5,000 columns. This vectorised operation: A function that takes whole arrays, such as a NumPy array of geometries, and loops over them in compiled code, so that one call from Python answers thousands of questions. style is the main change from Shapely 1. The program below makes the town’s households and wards, counts the households of each ward from that table, and checks the counts against plain arithmetic on the grid:

town.py: the track's town, in metres of UTM zone 43N Python · shapes/town.py
"""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()]
5,000 households against 12 wards in one call Python · shapes/arrays.py
"""5,000 households tested against 12 wards in one vectorised call, checked against plain arithmetic, and clinic C2,
which stands on the edge of W02 and W03, under four predicates."""
import numpy as np
import shapely
from town import COLS, E0, N0, ROWS, WARD, clinics, households, ward_bounds

homes = households()
xy = np.array([(x, y) for _, x, y in homes])  # a 5,000 x 2 array of coordinates
points = shapely.points(xy)  # 5,000 Point objects, made in one call
wards = np.array([shapely.box(*b) for b in ward_bounds().values()])
names = list(ward_bounds())
print(f"{points.size} points in a NumPy array of dtype {points.dtype}; the first is {points[0]}")

# One call compares every ward with every household: a 12 x 5,000 table of True and False.
inside = shapely.contains(wards[:, np.newaxis], points[np.newaxis, :])
counts = inside.sum(axis=1)
print(f"contains() answered {inside.size:,} questions; table shape {inside.shape}")
print("households per ward:", ", ".join(f"{w} {n}" for w, n in zip(names, counts)))

# The same counts by arithmetic: a household's column and row of the grid follow from its x and y.
col = ((xy[:, 0] - E0) // WARD).astype(int)
row = ROWS - 1 - ((xy[:, 1] - N0) // WARD).astype(int)
by_arithmetic = np.bincount(row * COLS + col, minlength=COLS * ROWS)
print("same as arithmetic:", counts.tolist() == by_arithmetic.tolist(), "| total", int(counts.sum()))

c2 = shapely.Point(next((x, y) for k, x, y in clinics() if k == "C2"))
print(f"\nC2 at {c2}: intersects within touches covered_by")
for name in ["W02", "W03"]:
    ward = wards[names.index(name)]
    answers = [shapely.intersects(c2, ward), shapely.within(c2, ward), shapely.touches(c2, ward), shapely.covered_by(c2, ward)]
    print(f"  against {name}: " + "  ".join(f"{str(a):>5}" for a in answers))
print("wards that contain C2:", int(shapely.contains(wards, c2).sum()), "| wards C2 intersects:", int(shapely.intersects(wards, c2).sum()))

Output

5000 points in a NumPy array of dtype object; the first is POINT (502076.4 2004143.9)
contains() answered 60,000 questions; table shape (12, 5000)
households per ward: W01 295, W02 696, W03 183, W04 1169, W05 204, W06 416, W07 379, W08 340, W09 126, W10 151, W11 648, W12 393
same as arithmetic: True | total 5000

C2 at POINT (504000 2005000): intersects within touches covered_by
  against W02:  True  False   True   True
  against W03:  True  False   True   True
wards that contain C2: 0 | wards C2 intersects: 2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 arrays.py

One call answered 60,000 questions, and the counts are the ones arithmetic gives: a household’s column is its distance east of the town’s corner divided by 2,000 m, rounded down, and likewise its row. Two methods that agree are the best evidence that a spatial count is right. The map shows the result:

Map of the 12 wards with one dot for every 10 households and each ward's count: W04 has 1,169, W09 has 126.Households per ward: one call, 60,000 testsW01295W02696W03183W041,169W05204W06416W07379W08340W09126W10151W11648W12393C21 dot = 10 householdshouseholds in the wardclinic C2, on the edge of W02 and W03Wards are 2 km squares; the counts add up to 5,000.

Households per ward, as one vectorised call counts them

Text description of the diagram

The town is 8 km wide and 6 km tall, divided into 12 square wards of 2 km. Wards W01 to W04 form the north row from west to east, W05 to W08 the middle row and W09 to W12 the south row. Grey dots show one household in ten; they crowd in W02, W04, W06 and W11 and thin out in W05, W09 and W10.

A box in each ward gives its count of households: W01 295, W02 696, W03 183, W04 1,169, W05 204, W06 416, W07 379, W08 340, W09 126, W10 151, W11 648 and W12 393. The twelve counts add up to 5,000, so every household lies inside exactly one ward.

A blue square marks clinic C2, which stands exactly on the edge between W02 and W03, at 504,000 m east and 2,005,000 m north.

The last lines test clinic C2, which stands on the edge of W02 and W03, with four spatial predicate: A test that answers true or false about how two geometries relate, such as intersects, within, contains, touches or covered by. Joins and selections by location are built on them.. It intersects, touches and is covered by both wards, and is within neither, so a count by contains finds it in no ward at all. The lesson on geometry types and validity explains why with the DE-9IM: The dimensionally extended nine-intersection model: a 3 by 3 matrix of the dimensions where the interiors, boundaries and exteriors of two geometries meet, written as nine characters such as FF2F11212. More in “Geometry types, WKT, WKB and validity”; the next lessons turn it into a rule for joins.

A Python loop over geometries is the slow way to the same answer

[ward.contains(p) for p in points] returns the same answers as shapely.contains(ward, points), but it crosses from Python into GEOS once for every point. Keep geometries in arrays and call the module-level function on the whole array; reach for a loop only when each step depends on the one before.

Checkpoint

Check: Vectorised calls on arrays of geometries

  1. Question 1 of 2 wards is an array of 12 polygons and points an array of 5,000 points. What does shapely.contains(wards[:, np.newaxis], points[np.newaxis, :]) return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An array of 12 rows and 5,000 columns of True and False

    The new axes make the wards a column of 12 and the points a row of 5,000, so NumPy's broadcasting pairs each ward with each point: a 12 × 5,000 table of booleans. The program sums each row to count households, 1,169 in W04 for example, and the counts match plain arithmetic on the grid.

  2. Question 2 of 2 Clinic C2 stands exactly on the edge between W02 and W03. In how many wards does a count with contains(ward, point) put it, and in how many does a count with intersects put it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In none with contains, in two with intersects

    A point on a shared edge lies on the boundary of both wards. Contains and within need it in the interior, so they put C2 in no ward; intersects, touches and covered_by accept the boundary, so they put it in both. The last line of arrays.py prints 0 and 2.

A buffer is a polygon in the units of its coordinates

A buffer: The zone within a given distance of a feature, such as the area within 1 km of a clinic. Counting what lies inside a buffer answers many proximity questions. More in “What GIS is and the questions it answers” of a point is not a circle: it is a polygon whose corners lie on the circle, and quad_segs sets “the number of linear segments in a quarter circle” (Shapely, buffer). The two spellings have different defaults. The function shapely.buffer uses 8 segments a quarter, while the method geometry.buffer uses 16 (Shapely manual). The figure draws clinic C2’s 1 km buffer at four settings:

Four buffers of the same point drawn inside a dashed circle: 4, 8, 16 and 32 sides, covering 63.66 % to 99.36 % of it.A 1 km buffer drawn with 4 to 32 sidesquad_segs=1: 4 sides63.66% of the circlequad_segs=2: 8 sides90.03% of the circlequad_segs=4: 16 sides97.45% of the circlequad_segs=8: 32 sides99.36% of the circleDashed: the true circle. Blue: the polygon.

The same 1 km buffer with quad_segs 1, 2, 4 and 8

Text description of the diagram

Four panels show a dashed circle of radius 1 km around clinic C2 and, inside it, the polygon that Shapely's buffer makes with a given number of segments per quarter circle. Every corner of each polygon lies on the circle.

With quad_segs 1 the buffer is a diamond of 4 sides that covers 63.66 % of the circle. With quad_segs 2 it is an octagon of 8 sides covering 90.03 %. With quad_segs 4 it has 16 sides and covers 97.45 %, and with quad_segs 8, the default of the shapely.buffer function, 32 sides covering 99.36 %. The gap between polygon and circle shrinks with every doubling of the sides.

Each doubling of the sides closes most of the gap that is left. The program measures both defaults, then the share of the circle at six settings:

Sides, areas and units of a 1 km buffer Python · shapes/buffers.py
"""A buffer is a polygon that approximates a circle. The method and the function use different numbers of segments
by default, quad_segs sets how many, and the distance is in the units of the coordinates."""
import math
import warnings

import geopandas as gpd
import numpy as np
import shapely
from pyproj import Transformer
from town import clinics

c2 = shapely.Point(next((x, y) for k, x, y in clinics() if k == "C2"))
circle = math.pi * 1_000**2  # the true area of a 1 km circle, in square metres

method = c2.buffer(1_000)  # the geometry's method: quad_segs=16 by default
function = shapely.buffer(c2, 1_000)  # the vectorised function: quad_segs=8 by default
for label, ring in [("c2.buffer(1000)", method), ("shapely.buffer(c2, 1000)", function)]:
    sides = shapely.get_num_coordinates(ring) - 1  # the ring repeats its first position at the end
    print(f"{label:<25} {sides:>3} sides, {ring.area:,.0f} m², {ring.area / circle:.3%} of the circle")

print("\nquad_segs  sides  share of the circle")
for q in [1, 2, 4, 8, 16, 32]:
    ring = shapely.buffer(c2, 1_000, quad_segs=q)
    print(f"{q:>9}  {4 * q:>5}  {ring.area / circle:.5f}")

# The same 'radius' in degrees of longitude and latitude is not a circle on the ground.
to_lonlat = Transformer.from_crs("EPSG:32643", "EPSG:4326", always_xy=True)
to_utm = Transformer.from_crs("EPSG:4326", "EPSG:32643", always_xy=True)
lon, lat = to_lonlat.transform(c2.x, c2.y)
in_degrees = shapely.buffer(shapely.Point(lon, lat), 0.009, quad_segs=16)
on_ground = shapely.transform(in_degrees, lambda c: np.column_stack(to_utm.transform(c[:, 0], c[:, 1])))
x0, y0, x1, y1 = on_ground.bounds
print(f"\nC2 is at {lon:.4f}° E, {lat:.4f}° N. A buffer of 0.009 degrees, measured in metres:")
print(f"  {x1 - x0:,.0f} m west to east, {y1 - y0:,.0f} m south to north, {on_ground.area / 1e6:.3f} km²")

# GeoPandas knows the CRS of its data, so it can warn about the same mistake.
with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    gpd.GeoSeries([shapely.Point(lon, lat)], crs="EPSG:4326").buffer(0.009)
print("GeoPandas warns:", str(caught[0].message).strip())

Output

c2.buffer(1000)            64 sides, 3,136,548 m², 99.839% of the circle
shapely.buffer(c2, 1000)   32 sides, 3,121,445 m², 99.359% of the circle

quad_segs  sides  share of the circle
        1      4  0.63662
        2      8  0.90032
        4     16  0.97450
        8     32  0.99359
       16     64  0.99839
       32    128  0.99960

C2 is at 75.0378° E, 18.1339° N. A buffer of 0.009 degrees, measured in metres:
  1,904 m west to east, 1,991 m south to north, 2.973 km²
GeoPandas warns: Geometry is in a geographic CRS. Results from 'buffer' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 buffers.py

The method’s buffer has 64 sides and 3,136,548 m², the function’s 32 sides and 3,121,445 m², so two programs that “buffer by 1 km” disagree by 15,103 m² for one clinic. Set quad_segs explicitly in any code whose areas are reported. GeoPandas 1.2 deprecated its own name for the setting, resolution, in favour of quad_segs (GeoPandas changelog).

The distance has no unit of its own. Shapely “does not support coordinate system transformations” and assumes every feature is in “the same Cartesian plane” (the manual), so 1,000 means 1,000 metres only because the town is in UTM (Universal Transverse Mercator): A system of 60 zones, each 6° of longitude wide and each a transverse Mercator projection with a scale factor of 0.9996 on its central meridian. Mainland India spans zones 42N to 47N. More in “Map projections and their distortions”. On longitude and latitude, a buffer of 0.009 is 0.009 degrees: at C2, 18.1° north, that is 1,904 m from west to east and 1,991 m from south to north, an ellipse about 4 % narrower than it is tall. Shapely cannot see the problem; GeoPandas can, because a GeoSeries carries its coordinate reference system (CRS): The definition that gives coordinates a meaning on the Earth: what the numbers measure, in which units and from which origin, including the datum and, for flat maps, the map projection. More in “What GIS is and the questions it answers”, and the last line of the output is its warning.

GPS Coordinate Converter (Lat/Long, DMS, UTM, MGRS) Convert C2's position, UTM zone 43N 504000 2005000, to degrees and back: it lies at 18.13° N, where a degree of longitude spans about 106 km.

Buffering degrees as if they were metres

points.buffer(0.009) on longitude and latitude makes ellipses whose width changes with latitude. Transform to a projected CRS in metres first, buffer by 1,000, and transform the result back only if a map needs degrees.

Checkpoint

Check: A buffer's sides and units

  1. Question 1 of 2 How many sides does the polygon of shapely.buffer(point, 250) have, with the function's default settings?

    Type a number.

    A hint

    The function's default quad_segs is 8, and a circle has four quarters.

    Show the answer: Question 1 of 2

    Answer: 32 sides

    The function shapely.buffer uses quad_segs=8 by default: 8 segments in each of 4 quarters, 32 sides, whatever the distance. The method point.buffer(250) would use 16 a quarter, 64 sides.

  2. Question 2 of 2 Clinic C2 is at 75.0378° E, 18.1339° N. Someone buffers that longitude and latitude by 0.009, thinking of 1 km. What shape is it on the ground?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: About 1,904 m from west to east and 1,991 m from south to north

    Shapely buffers in the units of the coordinates. 0.009 degrees of latitude is about 996 m, but 0.009 degrees of longitude at 18.13° N is about 952 m, so the buffer measured in UTM is 1,904 m wide and 1,991 m tall. Buffer in a projected CRS in metres instead.

Unions count overlaps once, and differences measure gaps

The eight clinics’ buffers add up to 25.092 km², but three pairs overlap, so the area they cover is smaller. shapely.union_all merges an array of geometries in one call, overlaps counted once; unary_union is another name for it (Shapely, union_all). From the union, difference gives what one shape leaves of another, and the The parts of two shapes that lie in one of them but not in both: their union minus their intersection. gives what lies in exactly one of the two. Look at the union over the town:

Map of the town with a 1 km circle around each of the 8 clinics; the circles, merged into one union, cover half the town.1 km around the 8 clinics, as one unionW01W02W03W04W05W06W07W08W09W10W11W12C1C2C3C4C5C6C7C8within 1 km of a clinic: 23.971 km² of the townnot covered: 24.029 km²Overlapping circles count once in the union.

The union of eight 1 km buffers over the 12 wards

Text description of the diagram

The 12 wards of the town are drawn as a grid of 2 km squares. Around each clinic, C1 to C8, a circle of 1 km is outlined in blue, and the area inside any circle is shaded. Where circles overlap, around C3 and C5, C5 and C7, and C7 and C8, the shading is the same as elsewhere: the union counts every place once.

The shaded area inside the town is 23.971 km² of its 48 km², and 24.029 km² is not covered. Ward W10, in the south row, has no shading at all, while most of W12 is covered. Parts of the circles around C1, C6 and C8 lie beyond the edge of the town, 0.724 km² in all.

The figure shows five separate patches; the largest is a chain of four overlapping circles in the east, C3, C5, C7 and C8. The program measures them:

Eight buffers as one union, and what they leave out Python · shapes/sets.py
"""Eight 1 km clinic buffers combined: the union counts each overlap once, one test against the union replaces
eight, and difference and symmetric difference measure what the buffers leave out of the town."""
import itertools

import numpy as np
import shapely
from town import COLS, E0, N0, ROWS, WARD, clinics, households

names = [k for k, _, _ in clinics()]
sites = shapely.points([(x, y) for _, x, y in clinics()])
rings = shapely.buffer(sites, 1_000, quad_segs=16)  # 8 polygons from one call
cover = shapely.union_all(rings)
print(f"8 buffers: {shapely.area(rings).sum() / 1e6:.3f} km² added up, {cover.area / 1e6:.3f} km² as a union")
print(f"the union is a {cover.geom_type} of {shapely.get_num_geometries(cover)} parts")
for (a, ra), (b, rb) in itertools.combinations(zip(names, rings), 2):
    shared = shapely.intersection(ra, rb).area
    if shared > 0:
        print(f"  {a} and {b} overlap by {shared:,.0f} m²")

homes = shapely.points([(x, y) for _, x, y in households()])
covered = shapely.intersects(cover, homes)  # one polygon against 5,000 points
near = shapely.dwithin(homes[:, np.newaxis], sites[np.newaxis, :], 1_000).any(axis=1)  # true distances
print(f"\nhouseholds inside the union: {int(covered.sum()):,}; within 1,000 m of a clinic: {int(near.sum()):,}")

town = shapely.box(E0, N0, E0 + COLS * WARD, N0 + ROWS * WARD)
uncovered = shapely.difference(town, cover)
outside = shapely.difference(cover, town)
either = shapely.symmetric_difference(town, cover)
print(f"town {town.area / 1e6:.3f} km²; not covered {uncovered.area / 1e6:.3f} km²; buffers beyond the town {outside.area / 1e6:.3f} km²")
print(f"symmetric difference {either.area / 1e6:.3f} km² = the two together")

Output

8 buffers: 25.092 km² added up, 24.695 km² as a union
the union is a MultiPolygon of 5 parts
  C3 and C5 overlap by 34,498 m²
  C5 and C7 overlap by 267,631 m²
  C7 and C8 overlap by 95,095 m²

households inside the union: 2,971; within 1,000 m of a clinic: 2,973
town 48.000 km²; not covered 24.029 km²; buffers beyond the town 0.724 km²
symmetric difference 24.753 km² = the two together

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sets.py

The union is 24.695 km², a MultiPolygon of 5 parts: the overlaps of C3 and C5, C5 and C7, and C7 and C8 add up to the 0.397 km² that the plain sum counted twice. Uncovered town and buffers beyond the town add up to the symmetric difference, 24.029 + 0.724 = 24.753 km².

The household counts hold a lesson of their own. 2,971 households lie inside the union, but 2,973 lie within 1,000 m of a clinic, measured as distances with shapely.dwithin, which the documentation calls “more efficient than computing the distance and comparing the result” (Shapely, dwithin). The two missing households stand in the slivers between a 64-sided polygon and its circle.

Key idea

Use a union of buffers for areas and maps, and a distance test such as dwithin for questions about “within d metres of”: the polygon is always a little smaller than the circle it stands for.

Checkpoint

Check: Unions, differences and distance tests

  1. Question 1 of 2 The eight 1 km buffers add up to 25.092 km² and their union is 24.695 km². How many square kilometres do the overlaps account for?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 0.397 km² (anything from 0.396 to 0.398 counts)

    The plain sum counts every overlap twice and the union once, so the difference, 25.092 − 24.695 = 0.397 km², is the area of the overlaps: C3 with C5, C5 with C7 and C7 with C8 in the program's output.

  2. Question 2 of 2 2,971 households lie inside the union of the 64-sided buffers, but 2,973 lie within 1,000 m of a clinic by dwithin. Which count answers "how many households live within 1 km of a clinic"?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 2,973: dwithin measures true distances, and the polygons miss two households between their sides and the circle

    A buffer polygon is inscribed in its circle, so it always covers slightly less than the true distance band. For "within d metres" questions use dwithin (or sjoin with predicate dwithin in GeoPandas) and keep buffers for areas and maps.

Repair the input first, then snap it to a grid

Set operations need valid input, and they are exact to the last bit of every coordinate. Both facts bite in real data. A ring that crosses itself is valid geometry: A polygon or multipolygon that follows the simple-features rules: simple rings that do not cross, touch only at points and keep the holes inside the shell, and an interior in one piece. Areas and overlays are reliable only on valid geometry. More in “Geometry types, WKT, WKB and validity”, and make_valid repairs it (Shapely, make_valid). Two parcels that share an edge, digitised by two people, can miss each other by less than a millimetre, and the union then keeps them apart. A precision grid: A grid of fixed spacing, such as 1 cm, onto which coordinates are snapped before and after an operation, so that positions closer than the spacing become one position and nearly meeting edges meet. fixes that: with grid_size, input coordinates are snapped to a grid of that size and so are the results (Shapely, intersection). Follow the lesson’s three shapes through the checks:

Interactive · stage by stage Make the input safe, then combine it Step through the bow-tie and the two parcels of precision.py as they pass the checks before a union.
Make the input safe, then combine it, step 7 of 7. Showing Read the shapes, Is it valid? (A and B: valid), make_valid (2 parts, 2 m²), Snap to a grid (edge at 502,500.00), Union or intersection (one Polygon), Check the result (1 part, 500,000 m²). In focus: Check the result.valid: no repairRead the shapesmetres of UTM zone 43NIs it valid?A and B: validmake_valid2 parts, 2 m²Snap to a gridedge at 502,500.00Union or intersectionone PolygonCheck the result1 part, 500,000 m²

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Three shapes arrive. A bow-tie parcel of 2 m² and two 500 m parcels, A and B, that share an edge at x = 502,500. They are in metres, so a grid of 0.01 means one centimetre.
  2. The bow-tie fails the check. is_valid_reason names the fault and where it is: Self-intersection[502001 2004001]. Its area reads 0 m², because the two halves of the ring cancel out.
  3. make_valid keeps the area. make_valid returns a MultiPolygon of 2 parts with an area of 2 m². Compare the area and the type before and after every repair: a change in either is a decision about what the shape was meant to be.
  4. The parcels are valid. A and B pass the check, so they skip the repair. The fault is between them: A was digitised 0.4 mm short of the edge it shares with B.
  5. Snap to a centimetre. set_precision(A, 0.01) moves every position of A to the nearest centimetre, so its east edge lands on x = 502,500.00, and A keeps that grid for later operations.
  6. The union is one parcel. On the grid, the union of A and B is one Polygon of 500,000 m². Without it the union is a MultiPolygon of 2 parts and 499,999.8 m², split by a gap no map shows.
  7. Check what came out. Read the type, the number of parts and the area of every result. One part and 500,000 m² is what two 500 m parcels side by side should give; two parts would have been the warning.

The program runs the same steps and adds the opposite case, a parcel that reaches 0.3 mm into its neighbour:

make_valid, then a 1 cm grid for edges that nearly meet Python · shapes/precision.py
"""Two repairs a pipeline needs: make_valid for a ring that crosses itself, and a precision grid for an edge that was
digitised twice and misses itself by less than a millimetre."""
import shapely
from shapely import Polygon


def describe(g):
    parts = shapely.get_num_geometries(g)
    return f"a {g.geom_type} of {parts} parts" if parts > 1 else f"one {g.geom_type}"


bowtie = Polygon([(502_000, 2_004_000), (502_002, 2_004_002), (502_002, 2_004_000), (502_000, 2_004_002)])
fixed = shapely.make_valid(bowtie)
print(f"bow-tie: {shapely.is_valid_reason(bowtie)}, area {bowtie.area:g} m²")
print(f"make_valid: {describe(fixed)}, area {fixed.area:g} m²")

# Two parcels of 500 m by 500 m that share an edge at x = 502,500. Parcel A was digitised 0.4 mm short of it.
a = Polygon([(502_000, 2_004_000), (502_499.9996, 2_004_000), (502_499.9996, 2_004_500), (502_000, 2_004_500)])
b = Polygon([(502_500, 2_004_000), (503_000, 2_004_000), (503_000, 2_004_500), (502_500, 2_004_500)])
print()
for grid in [None, 0.01]:
    u = shapely.union(a, b, grid_size=grid)
    print(f"union of A and B, grid_size={grid}: {describe(u)}, {u.area:,.1f} m²")

snapped = shapely.set_precision(a, 0.01)  # every position moves to the nearest centimetre, and stays on that grid
print(f"set_precision(A, 0.01): east edge at x = {snapped.bounds[2]:,.2f}, precision {shapely.get_precision(snapped)}")
print(f"then a plain union with B: {describe(shapely.union(snapped, b))}")

# The other way round: parcel C reaches 0.3 mm into B, and their overlap is a sliver.
c = Polygon([(502_000, 2_004_000), (502_500.0003, 2_004_000), (502_500.0003, 2_004_500), (502_000, 2_004_500)])
sliver = shapely.intersection(c, b)
print(f"\nintersection of C and B: {describe(sliver)} of {sliver.area:.2f} m²")
print(f"the same on a 1 cm grid: {shapely.intersection(c, b, grid_size=0.01)}")

Output

bow-tie: Self-intersection[502001 2004001], area 0 m²
make_valid: a MultiPolygon of 2 parts, area 2 m²

union of A and B, grid_size=None: a MultiPolygon of 2 parts, 499,999.8 m²
union of A and B, grid_size=0.01: one Polygon, 500,000.0 m²
set_precision(A, 0.01): east edge at x = 502,500.00, precision 0.01
then a plain union with B: one Polygon

intersection of C and B: one Polygon of 0.15 m²
the same on a 1 cm grid: LINESTRING (502500 2004000, 502500 2004500)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 precision.py

Without a grid the union of A and B is a MultiPolygon of 2 parts, 499,999.8 m², split by a gap no map shows; on a 1 cm grid it is one Polygon of 500,000 m². set_precision snaps a geometry once and keeps the grid, so later operations run “in the precision of the geometry with higher precision” (Shapely, set_precision). The 0.15 m² overlap of C and B shrinks to a line on the grid, because a 0.3 mm strip has no width at 1 cm.

Choose the grid from the data, not from the screen

Pick a grid finer than the accuracy of the survey and coarser than the noise between datasets: 1 cm for parcels measured to 5 cm whose shared edges differ by fractions of a millimetre. A 10 m grid would move real boundaries.

Checkpoint

Check: make_valid and precision grids

  1. Question 1 of 2 Parcel A was digitised 0.4 mm short of the edge it shares with parcel B. What does shapely.union(a, b) return without a grid?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A MultiPolygon of 2 parts, 499,999.8 m²

    Without a grid the union is exact to the last bit, so a gap of 0.4 mm separates the parcels: 2 parts and 499,999.8 m². With grid_size=0.01, or after set_precision(a, 0.01), the edges meet and the union is one Polygon of 500,000 m².

  2. Question 2 of 2 Parcels were surveyed to an accuracy of 5 cm, and the edges two teams digitised differ by up to 0.4 mm. Which grid_size would you use for their overlays?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0.01, one centimetre

    Choose a grid finer than the accuracy of the data and coarser than the noise between datasets. Here anything from about a millimetre to a few centimetres works; 1 cm is a round, defensible choice.

GEOS does the work, so its version is part of the result

GEOS computes every predicate, buffer and union above, and its version travels with each installation of Shapely. The wheels of Shapely 2.1.x bundle GEOS 3.13.1 and those of Shapely 2.2 bundle GEOS 3.14.1 (Shapely 2.x release notes), the browser build of Shapely 2.1.2 in Pyodide 314.0.7 links GEOS 3.12.1 (Pyodide package lock), and GEOS itself has reached 3.15.0 (GEOS releases). Walk through the four:

Timeline · 4 events in order The GEOS behind four Shapely installations Walk through the versions from the oldest engine to the newest, and note which one your own code meets.
  1. GEOS 3.12.1

    Shapely 2.1.2 in Pyodide, in the browser browser

    The browser build of Shapely 2.1.2 links GEOS 3.12.1, according to the package lock of Pyodide 314.0.7. The same Shapely version therefore runs an older engine in a browser than on a laptop.

  2. GEOS 3.13.1

    Shapely 2.1.2 wheels, as on this page these records

    The wheels of Shapely 2.1.0 to 2.1.2 bundle GEOS 3.13.1. Every recorded output in this lesson ran with it, and compare.py prints the version beside its results.

  3. GEOS 3.14.1

    Shapely 2.2 wheels new functions

    Shapely 2.2 bundles GEOS 3.14.1 and adds functions that need it, such as coverage_clean. Moving a project to it changes the geometry engine as well as the Python package.

  4. GEOS 3.15.0

    A newer engine, through other builds native split

    GEOS 3.15.0 is a later release. Shapely 2.2 runs split() on a native GEOS path only with GEOS 3.15 or newer, so one Shapely version can behave differently with different GEOS builds.

The timeline has 4 events, from GEOS 3.12.1 to GEOS 3.15.0.

A new version may return the same shape with its vertices in another order, keep an extra vertex on a straight edge, or round a computed crossing in its last digit. A test that compares WKT text then fails although nothing is wrong. This program shows both kinds of difference and the comparisons that see through them:

The same shapes, written differently Python · shapes/compare.py
"""Compare geometries by shape, not by text. The same ward written from another corner has other WKT, the union of 12
wards keeps vertices that a box of the town does not have, and the GEOS version belongs beside every result."""
import shapely
from shapely import Polygon
from town import COLS, E0, N0, ROWS, WARD, ward_bounds

print(f"Shapely {shapely.__version__} with GEOS {shapely.geos_version_string}")

w02 = shapely.box(*ward_bounds()["W02"])
again = Polygon([(504_000, 2_006_000), (504_000, 2_004_000), (502_000, 2_004_000), (502_000, 2_006_000)])
print(f"\nW02 as box():     {w02.wkt}")
print(f"W02 typed again:  {again.wkt}")
print(f"same WKT: {w02.wkt == again.wkt} | equals: {shapely.equals(w02, again)} | "
      f"same after normalize: {shapely.normalize(w02).wkt == shapely.normalize(again).wkt}")

town = shapely.union_all([shapely.box(*b) for b in ward_bounds().values()])
outline = shapely.box(E0, N0, E0 + COLS * WARD, N0 + ROWS * WARD)
print(f"\nunion of the 12 wards: {shapely.get_num_coordinates(town)} positions; box of the town: {shapely.get_num_coordinates(outline)}")
print(f"equals: {shapely.equals(town, outline)} | equals_exact: {shapely.equals_exact(town, outline, tolerance=0)} | "
      f"area difference: {abs(town.area - outline.area)} m²")
tidy = shapely.simplify(town, 0)  # drops the positions that lie on a straight edge
print(f"after simplify(0): {shapely.get_num_coordinates(tidy)} positions, same normalized WKT: "
      f"{shapely.normalize(tidy).wkt == shapely.normalize(outline).wkt}")

Output

Shapely 2.1.2 with GEOS 3.13.1

W02 as box():     POLYGON ((504000 2004000, 504000 2006000, 502000 2006000, 502000 2004000, 504000 2004000))
W02 typed again:  POLYGON ((504000 2006000, 504000 2004000, 502000 2004000, 502000 2006000, 504000 2006000))
same WKT: False | equals: True | same after normalize: True

union of the 12 wards: 15 positions; box of the town: 5
equals: True | equals_exact: False | area difference: 0.0 m²
after simplify(0): 5 positions, same normalized WKT: True

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 compare.py

W02 typed from another corner has other WKT, but equals says the shapes are the same and normalize gives both the same text; the documentation calls normalize “typically useful for testing purposes” (Shapely, normalize). The union of the 12 wards keeps 15 positions where the town’s box has 5, so equals_exact, which requires the coordinates “in the same order” (Shapely, equals_exact), says no; after simplify(0) the normalized texts match.

Key idea

Record the GEOS version beside every result, and test geometry by shape: equals, areas within a tolerance, or normalized WKT after simplify(0), never raw WKT.

Checkpoint

Check: GEOS versions and comparing results

  1. Question 1 of 2 A test compares the WKT of an overlay result with a stored string. It passes on GEOS 3.13.1 and fails after an upgrade to GEOS 3.14.1, although the map looks the same. What is the best fix?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Compare by shape, with equals, or areas within a tolerance, or normalized WKT after simplify(0)

    WKT text depends on vertex order and on vertices that do not change the shape. compare.py shows W02 written from another corner and the union of the wards with 15 positions: both fail a text comparison and pass equals.

  2. Question 2 of 2 The union of the 12 wards has 15 positions and the town's box has 5. Which comparison says they are the same?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: equals, which tests topological equality

    equals asks whether two geometries cover the same points, so it says yes. equals_exact and WKT text compare descriptions, which differ by 10 positions on straight edges. After simplify(0) the normalized WKT texts match too.

Practise: three helpers of a geometry toolkit

Build three small helpers in the browser, each with sample tests: one that predicts how much of the circle a buffer covers, one that snaps a ring to a precision grid, and one that compares two rings by shape:

Code lab · 3 steps

Build a geometry toolkit

Predict a buffer's area from quad_segs, snap a ring to a grid as GEOS does, and test two rings for the same shape.

  1. Predict how much of the circle a buffer covers To do
  2. Snap a ring to a precision grid To do
  3. Compare two rings by shape, not by text To do

Exercise · Easy · Python

Predict how much of the circle a buffer covers

Shapely draws a buffer around a point as a regular polygon whose corners lie on the circle, with quad_segs sides in each quarter. Write two functions in sides.py. buffer_share(quad_segs) returns the polygon's area as a share of the circle's area: a polygon of n sides with its corners on a circle of radius r has an area of n × r² × sin(2π ÷ n) ÷ 2. quad_segs_for(share) returns the smallest quad_segs whose polygon covers at least that share of the circle.

The sample tests use the shares the lesson measured on clinic C2's 1 km buffer.

Starter code · sides.py

import math


def buffer_share(quad_segs):
    """The area of a buffer polygon with quad_segs sides per quarter circle, as a share of the circle's area."""
    # Replace this line: n = 4 * quad_segs sides, an area of n * r**2 * sin(2 * pi / n) / 2 against pi * r**2.
    return 0.0


def quad_segs_for(share):
    """The smallest quad_segs whose buffer polygon covers at least `share` of the circle."""
    # Replace this line: try 1, 2, 3 … until buffer_share reaches the share.
    return 0
The sample tests · test_sides.py
import math

from sides import buffer_share, quad_segs_for


def test_one_segment_is_a_diamond():
    """gives 2 / pi for quad_segs=1, a four-sided diamond"""
    assert math.isclose(buffer_share(1), 2 / math.pi, rel_tol=1e-12)


def test_the_measured_shares():
    """matches the shares Shapely's buffers of C2 measured, to five decimals"""
    measured = {2: 0.90032, 4: 0.9745, 8: 0.99359, 16: 0.99839, 32: 0.9996}
    for q, share in measured.items():
        assert round(buffer_share(q), 5) == share


def test_more_sides_cover_more():
    """grows with every extra segment and stays below the whole circle"""
    shares = [buffer_share(q) for q in range(1, 41)]
    assert all(a < b for a, b in zip(shares, shares[1:]))
    assert shares[-1] < 1


def test_segments_for_a_share():
    """finds the smallest quad_segs for a share: 1 for half, 7 for 99 %, 21 for 99.9 %"""
    assert quad_segs_for(0.5) == 1
    assert quad_segs_for(0.99) == 7
    assert quad_segs_for(0.999) == 21
    assert buffer_share(quad_segs_for(0.995)) >= 0.995 > buffer_share(quad_segs_for(0.995) - 1)
A hint

With n = 4 × quad_segs, the share is the polygon's area divided by π × r², so r cancels: n × sin(2π ÷ n) ÷ (2π). For quad_segs_for, start at 1 and add 1 while buffer_share(q) is below the share asked for.

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.

Exercise · Easy · Python

Snap a ring to a precision grid

Write snap_ring(ring, grid) in snap.py. A ring is a list of (x, y) positions whose last equals its first. Move every coordinate to the nearest multiple of grid, round the result to 9 decimals so that 0.01 steps print cleanly, and drop a position that equals the one before it, as GEOS does on a precision grid. The ring you return still ends where it starts.

The sample tests snap parcel A of the lesson, digitised 0.4 mm short of its neighbour's edge, to a 1 cm grid.

Starter code · snap.py

def snap_ring(ring, grid):
    """The ring with every position moved to the nearest multiple of grid, and repeated positions dropped."""
    # Replace this line: snap x and y, round to 9 decimals, skip a position equal to the previous one.
    return list(ring)
The sample tests · test_snap.py
from snap import snap_ring

PARCEL_A = [(502000, 2004000), (502499.9996, 2004000), (502499.9996, 2004500), (502000, 2004500), (502000, 2004000)]


def test_the_short_edge_reaches_the_neighbour():
    """moves parcel A's east edge from 502,499.9996 to 502,500 on a 1 cm grid"""
    snapped = snap_ring(PARCEL_A, 0.01)
    assert snapped == [(502000.0, 2004000.0), (502500.0, 2004000.0), (502500.0, 2004500.0), (502000.0, 2004500.0), (502000.0, 2004000.0)]


def test_positions_that_meet_become_one():
    """drops a position 3 mm from the previous one, because both land on the same centimetre"""
    ring = [(0, 0), (10, 0), (10.003, 0.002), (10, 10), (0, 10), (0, 0)]
    assert snap_ring(ring, 0.01) == [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]


def test_halves_and_coarse_grids():
    """rounds to the nearest multiple of a 1 m grid and keeps the ring closed"""
    ring = [(0.4, 0.6), (5.2, 0.4), (5.6, 4.7), (0.4, 0.6)]
    assert snap_ring(ring, 1.0) == [(0.0, 1.0), (5.0, 0.0), (6.0, 5.0), (0.0, 1.0)]


def test_a_fine_grid_changes_nothing():
    """leaves positions alone when they already sit on the grid"""
    ring = [(1.25, 2.5), (3.75, 2.5), (3.75, 5.0), (1.25, 2.5)]
    assert snap_ring(ring, 0.25) == ring
A hint

round(x / grid) * grid is the nearest multiple; wrap it in round(…, 9). Build the result one position at a time and append a snapped position only when the list is empty or its last position differs.

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.

Exercise · Medium · Python

Compare two rings by shape, not by text

Write same_ring(a, b) in rings.py. Each ring is a list of (x, y) positions whose last equals its first. Return True when both rings visit the same positions in the same cyclic order, starting anywhere and running either way, which is what makes two WKT texts of one polygon describe the same shape. Return False otherwise, also when one ring has an extra position on a straight edge: that ring needs simplify(0) before a test can match it this way.

The sample tests use ward W02 as box() writes it and as it was typed again from another corner.

Starter code · rings.py

def same_ring(a, b):
    """True when two closed rings visit the same positions in the same cyclic order, in either direction."""
    # Replace this line: compare a without its last position with each rotation of b, and of b reversed.
    return a == b
The sample tests · test_rings.py
from rings import same_ring

W02_BOX = [(504000, 2004000), (504000, 2006000), (502000, 2006000), (502000, 2004000), (504000, 2004000)]
W02_TYPED = [(504000, 2006000), (504000, 2004000), (502000, 2004000), (502000, 2006000), (504000, 2006000)]


def test_another_corner_and_direction():
    """accepts W02 typed from another corner, running the other way"""
    assert same_ring(W02_BOX, W02_TYPED)
    assert same_ring(W02_TYPED, W02_BOX)


def test_every_start_and_direction():
    """accepts every rotation of a ring, forwards and backwards"""
    core = W02_BOX[:-1]
    for i in range(4):
        turned = core[i:] + core[:i]
        assert same_ring(W02_BOX, turned + turned[:1])
        back = turned[::-1]
        assert same_ring(W02_BOX, back + back[:1])


def test_other_shapes():
    """refuses W03, and W02's corners visited in a crossing order"""
    w03 = [(506000, 2004000), (506000, 2006000), (504000, 2006000), (504000, 2004000), (506000, 2004000)]
    crossing = [(504000, 2004000), (502000, 2006000), (504000, 2006000), (502000, 2004000), (504000, 2004000)]
    assert not same_ring(W02_BOX, w03)
    assert not same_ring(W02_BOX, crossing)


def test_an_extra_position_needs_simplify():
    """refuses a ring with an extra position on a straight edge, as the union of two wards has"""
    extra = [(504000, 2004000), (504000, 2005000), (504000, 2006000), (502000, 2006000), (502000, 2004000), (504000, 2004000)]
    assert not same_ring(W02_BOX, extra)
A hint

Drop the closing position of each ring with [:-1]. Then compare a[:-1] with every rotation q[i:] + q[:i] of b[:-1], and with every rotation of b[:-1] reversed.

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.

The self-check below covers all five ideas of the lesson and asks first about the one you have answered least well:

Practice that adapts to you

Practise this lesson

15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 What does shapely.points(xy) return for a NumPy array xy of 5,000 rows and 2 columns?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: A NumPy array of 5,000 Point geometries

    shapely.points turns each row of coordinates into a Point and returns them as a NumPy array of 5,000 geometries, which arrays.py then compares with the wards in one call.

  2. Question 2 of 15 arrays.py checks the per-ward counts against arithmetic on the grid. Why is that check worth having?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: Two independent methods that give the same counts are good evidence that the spatial test is right

    Counting the same thing two ways is the cheapest test there is. Here the wards are squares, so a household's column and row follow from its coordinates, and both methods give W04 1,169, W09 126 and a total of 5,000.

  3. Question 3 of 15 You must find, for 200,000 points, which of 1,200 grid cells contains each one. Which approach follows this lesson?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: Keep the points and the cells in arrays and call the vectorised predicate on them, or a spatial join built on it

    The vectorised functions exist for exactly this: the same predicate over whole arrays, looped in C. GeoPandas' spatial join does it with an index on top, as the next lessons show.

  4. Question 4 of 15 Which call makes a 1 km buffer of 64 sides with its default settings?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: point.buffer(1000), the geometry's method

    The method uses quad_segs=16 and the function quad_segs=8, so their buffers have 64 and 32 sides and areas of 3,136,548 m² and 3,121,445 m². Pass quad_segs explicitly when the area matters.

  5. Question 5 of 15 To five decimals, what share of the true circle does a buffer with quad_segs=16 cover?

    Type a number.

    A hint

    Look at the table of buffers.py, or compute n × sin(2π ÷ n) ÷ (2π) with n = 64.

    Show the answer: Question 5 of 15

    Answer: 0.99839 of the circle (anything from 0.99838 to 0.9984 counts)

    With 16 segments a quarter the polygon has 64 sides, and its area is 0.99839 of the circle's: 3,136,548 m² against 3,141,593 m² for a 1 km circle.

  6. Question 6 of 15 A colleague's GeoDataFrame of shops is in EPSG:4326. They want everything within 500 m of each shop. What should the code do?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: Transform to a projected CRS in metres, buffer by 500, and transform back only if a map needs degrees

    Distances in Shapely and GeoPandas are in the units of the CRS. Reproject to metres first, as GeoPandas' warning suggests with GeoSeries.to_crs(), then buffer.

  7. Question 7 of 15 Which function merges an array of overlapping polygons into one geometry, counting each overlap once?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: shapely.union_all

    union_all merges the eight buffers into a MultiPolygon of 5 parts, 24.695 km², where the plain sum of their areas was 25.092 km².

  8. Question 8 of 15 Inside the town 24.029 km² is not covered by any buffer, and 0.724 km² of the buffers lies beyond the town. What is the area of the symmetric difference of the town and the union?

    Type a number.

    Show the answer: Question 8 of 15

    Answer: 24.753 km² (anything from 24.752 to 24.754 counts)

    The symmetric difference keeps what lies in exactly one of the two shapes: town without buffers plus buffers without town, 24.029 + 0.724 = 24.753 km², as sets.py prints.

  9. Question 9 of 15 A health department wants a map of the area within 1 km of its clinics and the number of households within 1 km. Which pair of methods fits?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: The union of the buffers for the map's area, and a distance test such as dwithin for the count

    Use each tool for what it is exact at: a union for areas and maps, a distance predicate for "within d metres" counts.

  10. Question 10 of 15 The bow-tie parcel reports an area of 0 m². What does make_valid return for it?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: A MultiPolygon of 2 parts with an area of 2 m²

    The shoelace sum of the bow-tie's ring adds one half and subtracts the other, so it reads 0. make_valid returns a MultiPolygon of 2 parts and 2 m², which is why every repair should compare the area and the type before and after.

  11. Question 11 of 15 What does set_precision(a, 0.01) do that union(a, b, grid_size=0.01) does not?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: It snaps the geometry once and keeps the 1 cm grid on it, so later operations with it run on that grid

    grid_size sets the precision for one overlay. set_precision rounds the geometry's coordinates and attaches the precision, so a plain union with B is already one Polygon.

  12. Question 12 of 15 Parcel C reaches 0.3 mm into parcel B, and their intersection is a sliver of 0.15 m². What does the same intersection give on a 1 cm grid, and what should a pipeline do with it?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: A LineString along the shared edge; keep only polygons when the result must be areas

    On the grid the overlap collapses to the shared edge, LINESTRING (502500 2004000, 502500 2004500). Overlays in GeoPandas keep or drop such lower-dimensional pieces with keep_geom_type, which the overlay lesson takes up.

  13. Question 13 of 15 What does shapely.normalize do to a geometry?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: It writes the geometry in a strict canonical form, with its coordinates, rings and parts in a consistent order

    normalize gives a canonical order, which the documentation calls useful for testing: W02 typed from another corner and W02 from box() have the same normalized WKT.

  14. Question 14 of 15 The same script with Shapely 2.1.2 runs in a browser through Pyodide and on a laptop with the PyPI wheel. Which GEOS versions run it?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: GEOS 3.12.1 in the browser and GEOS 3.13.1 on the laptop

    Each environment brings its own GEOS. Record shapely.geos_version_string with every result, as compare.py does, so a difference between two runs can be traced to the engine.

  15. Question 15 of 15 You are writing a regression test for a function that dissolves wards into zones. Which assertion survives a GEOS upgrade and still catches a real mistake?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: Each zone's area matches the expected area within 0.01 m², and equals(result, expected) is True

    A good geometry test compares what the shapes cover: equals, areas within a tolerance, or normalized WKT after simplify(0). It ignores vertex order and collinear vertices and still fails when a ward is missing.

Interview questions

Warm-up (fresher): Shapely has no coordinate reference system. What does that mean for point.buffer(1000)?

Shapely works in a flat plane and treats coordinates as plain numbers, so the 1,000 is in whatever units the coordinates use. In metres of a projected CRS such as UTM zone 43N it is a 1 km buffer. On longitude and latitude it is 1,000 degrees, nonsense, and even a small value such as 0.009 makes an ellipse: 1,904 m wide and 1,991 m tall at 18° north. The fix is to transform the data to a projected CRS in metres, buffer there, and transform back only for display. GeoPandas, which does know the CRS, warns when you buffer in a geographic one. Mention too that the buffer is a polygon: 64 sides by default for the method, 32 for shapely.buffer.

Two harder questions continue from here: a coverage count that disagrees with a distance count, and a geometry test that fails after an upgrade. The module’s question bank says, for each, what it tests and how to begin:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A coverage report counts the households inside the union of 1 km buffers around 8 clinics and finds 2,971. A distance query on the same data finds 2,973 households within 1,000 m of a clinic. Explain the difference, say which number you would publish, and how you would make the pipeline give one answer.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A regression test compares the WKT of a dissolved zone with a stored string. After a dependency upgrade the test fails in CI, although the maps look identical and nobody changed the code. What do you suspect, how do you confirm it, and how should the test be written?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How does Shapely 2 test 5,000 points against 12 wards in one call?

    Its functions are NumPy ufuncs that loop in C. shapely.points turns a 5,000 × 2 array into points, and shapely.contains(wards[:, np.newaxis], points[np.newaxis, :]) broadcasts into a 12 × 5,000 table of answers; a Python loop would cross into GEOS once per question.

  2. Which predicates hold for clinic C2, which lies exactly on the shared edge of wards W02 and W03?

    It intersects, touches and is covered by both wards, and is within neither, so a count made with contains places it in no ward at all.

  3. How many sides has a Shapely buffer by default, and why does it matter?

    The method geometry.buffer uses quad_segs=16, 64 sides; the function shapely.buffer uses 8, 32 sides. For a 1 km buffer that is 3,136,548 m² against 3,121,445 m², so set quad_segs explicitly wherever areas are reported.

  4. In what unit is the distance of a Shapely buffer?

    In the units of the coordinates, since Shapely knows no CRS. 1,000 is 1 km only in metres of a projected CRS; a buffer of 0.009 on longitude and latitude at 18° N is an ellipse 1,904 m wide and 1,991 m tall. GeoPandas warns, because it knows the CRS.

  5. Why is the union of eight 1 km clinic buffers smaller than the sum of their areas?

    union_all counts every overlap once. The eight buffers summed to 25.092 km², but their union is 24.695 km², a MultiPolygon of 5 parts, because three pairs of circles overlap by 0.397 km² in all.

  6. Why did 2,973 households lie within 1,000 m of a clinic but only 2,971 inside the union of the buffers?

    Each buffer is a 64-sided polygon slightly smaller than its circle, and two households stood in the slivers between. Answer "within d metres" questions with a distance test such as dwithin, and keep the union of buffers for areas and maps.

  7. How does a precision grid fix the union of two parcels whose shared edges miss by a fraction of a millimetre?

    With grid_size, input and output coordinates are snapped to the grid. Unsnapped, the union was two parts totalling 499,999.8 m²; on a 1 cm grid it became one polygon of 500,000 m². Choose a grid finer than the survey's accuracy.

  8. Why should a geometry test never compare raw WKT text?

    GEOS computes the results, and its version differs between Shapely wheels, Pyodide and new releases, which may order vertices differently or keep an extra one. Compare with equals, areas within a tolerance, or normalized WKT after simplify(0), and record the GEOS version.

Key takeaways

  • Shapely 2’s functions take whole NumPy arrays of geometries and loop in C, so one call such as shapely.contains on a 12 × 5,000 table replaces a Python loop of 60,000 steps.
  • A buffer is a polygon with its corners on the circle, 64 sides from the method and 32 from the function by default, in the units of the coordinates; buffer only in a projected CRS.
  • union_all counts overlaps once, difference and symmetric_difference measure what is left, and a distance test is more exact than a test against a buffer polygon.
  • Repair invalid input with make_valid and join edges that nearly meet with grid_size or set_precision, choosing a grid finer than the survey’s accuracy.
  • GEOS does the geometry, and its version differs between wheels, browsers and newer releases, so record it and compare results by shape, not by text.

Terms in this lesson

GEOS (Geometry Engine, Open Source)
The C and C++ library that computes predicates, buffers, unions and other overlays for Shapely, PostGIS, QGIS and GDAL. Each installation bundles or links its own version, which can change the exact vertices of a result.
precision grid
A grid of fixed spacing, such as 1 cm, onto which coordinates are snapped before and after an operation, so that positions closer than the spacing become one position and nearly meeting edges meet.
spatial predicate
A test that answers true or false about how two geometries relate, such as intersects, within, contains, touches or covered by. Joins and selections by location are built on them.
vectorised operation
A function that takes whole arrays, such as a NumPy array of geometries, and loops over them in compiled code, so that one call from Python answers thousands of questions.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.