Distance and overlay g4
- PostgreSQL with PostGIS
- 2 hours
Prerequisites: Spatial joins (g3)
Measure in metres and cut one layer by another: areas and lengths on the ellipsoid, ST_DWithin and buffers, ST_Intersection and ST_Difference, and how far two separately drawn maps are from fitting. Everything runs in your own PostgreSQL with PostGIS, on the lab tables (the setup page).
Concept
How to measure lengths, areas and distances in metres, and how to cut one layer by another:
- Measure on
geography(Coordinate reference systems, g1). Cast first, andST_Lengthof a line,ST_PerimeterandST_Areaof a polygon, andST_Distancebetween two shapes answer in metres and square metres, on the ellipsoid. On SRID 4326 geometry they answer in degrees. - "Within d?" is
ST_DWithin(a, b, d); ongeography, d is in metres. It gives the same answer asST_Distance(a, b) <= d, but it starts with a bounding-box test that a spatial index (Indexes and query plans, d3) can answer, and measures only the shapes that pass.ST_Distance(a, b) < dmeasures every pair. The index must be on the expression the query compares: casting a geometry column togeographyin the query leaves the column's geometry index unused (Spatial indexes, g5, shows the plans). - A buffer is the area within a distance of a shape, as a polygon:
ST_Buffer(g, d), ArcGIS Pro's Buffer. Ongeography, PostGIS picks a flat system that suits the shape (a UTM zone, or a polar Lambert azimuthal one), buffers there in metres and turns the result back into longitude and latitude. Curves are drawn as straight segments, 8 to a quarter circle by default, so the polygon falls a little inside the true distance. To ask whether something lies within d, useST_DWithin, which is exact and faster; draw a buffer when you need the area itself, to measure it, map it or cut another layer with it. - Overlay builds new shapes from two inputs' edges.
ST_Intersection(a, b)keeps what both cover (ArcGIS Pro's Intersect, or Clip when b is one shape);ST_Difference(a, b)keeps the part of a outside b (Erase);ST_Unionmerges shapes into one, and as an aggregate it merges a column's rows (Dissolve). Overlays are much slower than predicates, so let a predicate the index answers pick the pairs (JOIN … ON ST_Intersects(…)) and overlay only those. An overlay can return lines and points where shapes only touch:ST_CollectionExtract(g, 2)keeps the lines,3the polygons. An invalid input can stop it with aTopologyException(Loading and validating shapes, g2). - Two maps never fit exactly. Layers drawn, or simplified, apart disagree along every line they share. The lab's ecoregions were simplified one at a time to 0.05°, about 5 km, and the country outlines on their own, so neighbouring ecoregions overlap in places and pull apart in others, and the ecoregions stop short of the outline's coast. Cut by an outline, the pieces add up to a little more or less than the whole: an overlay should report by how much, since that is the error any area it gives carries.
- Valid isn't a coverage (g2). Each ecoregion here is a valid shape;
ST_IsValidchecks one row at a time and can't see that two rows overlap. Only a test across pairs finds it, the one g2 ran on the country outlines.
Worked example
How much of Costa Rica does each ecoregion cover? Cut each ecoregion that touches the outline by it, measure the piece on the ellipsoid, and divide by the outline's area:
Checked 2026-11-02 on PostgreSQL 16.15, PostGIS 3.4.2, GEOS 3.12.1, GDAL 3.8.4.
g4-ecoregions · PostgreSQL with PostGIS
WITH cr AS (SELECT geom, ST_Area(geom::geography) AS m2 FROM country_shapes WHERE country_code = 'CR')
SELECT e.eco_id, e.name, b.name AS biome,
round((ST_Area(ST_Intersection(e.geom, cr.geom)::geography) / 1e6)::numeric) AS km2,
round((100 * ST_Area(ST_Intersection(e.geom, cr.geom)::geography) / cr.m2)::numeric, 1) AS pct
FROM ecoregions AS e
JOIN cr ON ST_Intersects(e.geom, cr.geom)
LEFT JOIN biomes AS b ON b.biome_id = e.biome_id
ORDER BY km2 DESC, e.eco_id;
-- result: g4-ecoregions · 7 rows · sha256 ed3884acd3d8 eco_id | name | biome | km2 | pct 470 | Isthmian-Atlantic moist forests | Tropical & Subtropical Moist Broadleaf Forests | 17288 | 33.7 471 | Isthmian-Pacific moist forests | Tropical & Subtropical Moist Broadleaf Forests | 9012 | 17.6 458 | Costa Rican seasonal moist forests | Tropical & Subtropical Moist Broadleaf Forests | 8400 | 16.4 506 | Talamancan montane forests | Tropical & Subtropical Moist Broadleaf Forests | 8311 | 16.2 527 | Central American dry forests | Tropical & Subtropical Dry Broadleaf Forests | 6062 | 11.8 … 2 more rows
In g4.sql, with the blocks it runs after.
Seven ecoregions reach into Costa Rica, led by the Isthmian-Atlantic moist forests (17,288 km², 33.7 %). Five are moist or dry broadleaf forest; the other two are mangroves along the coasts, Southern Mesoamerican Pacific (637 km²) and Mesoamerican Gulf-Caribbean (198 km²). ST_Intersects in the ON clause picks the ecoregions, with the index; ST_Intersection in the SELECT cuts only those seven. The shares add up to 97.3 %, not 100: the Lab asks why.
Lab
Do the pieces add up to the country? The outline's area, the pieces' sum, and the part of the outline that no ecoregion covers (the outline less the union of the ecoregions):
g4-gap · PostgreSQL with PostGIS
WITH cr AS (SELECT geom FROM country_shapes WHERE country_code = 'CR') SELECT round((ST_Area(cr.geom::geography) / 1e6)::numeric) AS country_km2, round((sum(ST_Area(ST_Intersection(e.geom, cr.geom)::geography)) / 1e6)::numeric) AS ecoregion_pieces_km2, round((ST_Area(ST_Difference(cr.geom, ST_Union(e.geom))::geography) / 1e6)::numeric) AS in_no_ecoregion_km2 FROM cr JOIN ecoregions AS e ON ST_Intersects(e.geom, cr.geom) GROUP BY cr.geom;-- result: g4-gap · 1 row · sha256 5811a1ff6de4 country_km2 | ecoregion_pieces_km2 | in_no_ecoregion_km2 51303 | 49908 | 2070
In g4.sql, with the blocks it runs after.
The pieces add up to 49,908 km² of the outline's 51,303, and 2,070 km² lie in no ecoregion. Had the pieces been a partition, the two would add up to the outline exactly; they add up to 51,978, about 675 km² too many. So the pieces both leave gaps and overlap. About half of the gap lies within 5 km of the sea, where the ecoregions stop short of the outline's coast; the rest is slivers inland, where two neighbouring ecoregions both pulled back from their shared border.
Where do the pieces overlap? Each pair of pieces whose interiors share an area (g2's
'2********'test), with its DE-9IM matrix, and a total row first:g4-overlaps · PostgreSQL with PostGIS
WITH cr AS (SELECT geom FROM country_shapes WHERE country_code = 'CR'), pieces AS ( SELECT e.eco_id, ST_Intersection(e.geom, cr.geom) AS geom FROM ecoregions AS e JOIN cr ON ST_Intersects(e.geom, cr.geom) ), shared AS ( SELECT a.eco_id || '+' || b.eco_id AS pair, ST_Relate(a.geom, b.geom) AS de9im, ST_Area(ST_Intersection(a.geom, b.geom)::geography) AS m2 FROM pieces AS a JOIN pieces AS b ON a.eco_id < b.eco_id AND ST_Relate(a.geom, b.geom, '2********') ) SELECT CASE WHEN GROUPING(pair) = 1 THEN 'all' ELSE pair END AS pair, de9im, round((sum(m2) / 1e6)::numeric, 1) AS overlap_km2 FROM shared GROUP BY ROLLUP ((pair, de9im)) ORDER BY GROUPING(pair) DESC, overlap_km2 DESC, pair;-- result: g4-overlaps · 11 rows · sha256 b0c2c01abe86 pair | de9im | overlap_km2 all | NULL | 678.2 470+506 | 212111212 | 181.7 458+527 | 212111212 | 143.8 458+506 | 212111212 | 76.3 458+471 | 212101212 | 62.0 … 6 more rows
In g4.sql, with the blocks it runs after.
Ten pairs overlap, 678.2 km² in all: the 675 km² that step 1's pieces counted twice (a few square kilometres lie in three ecoregions at once, and come into three pairs). The largest are between neighbours along long borders: the Isthmian-Atlantic moist forests and the Talamancan montane forests (181.7 km²), the seasonal moist and the dry forests (143.8). Each is a thin strip along a border that each ecoregion, simplified on its own, drew in a slightly different place. Compare the matrices with g2's
2F2F11FF2, a territory wholly inside its country: here the third and seventh characters are2too, so each piece also reaches outside the other. These are partial overlaps between shapes that are each valid: the layer isn't a coverage, and nothing about one row says so. Pieces that overlap are counted twice by any sum over them, as step 1's was; a partition (each overlap given to one ecoregion, each gap to the nearest) would need rules of its own, and the honest report is the overlay's error: here about 1.3 % of the country in overlaps and 4.0 % in gaps.Costa Rica's land borders, their length, and the records within 10 km of each, by the code GBIF gave them:
g4-border · PostgreSQL with PostGIS
WITH cr AS (SELECT geom FROM country_shapes WHERE country_code = 'CR'), border AS ( SELECT n.country_code AS neighbour, ST_LineMerge(ST_CollectionExtract(ST_Intersection(ST_Boundary(cr.geom), ST_Boundary(n.geom)), 2)) AS geom FROM cr JOIN country_shapes AS n ON n.country_code <> 'CR' AND ST_Touches(n.geom, cr.geom) ) SELECT b.neighbour, round((ST_Length(b.geom::geography) / 1000)::numeric) AS border_km, count(o.id) AS records_within_10km, count(o.id) FILTER (WHERE o.country_code_gbif = 'CR') AS coded_cr, count(o.id) FILTER (WHERE o.country_code_gbif = b.neighbour) AS coded_neighbour FROM border AS b LEFT JOIN occurrences AS o ON o.country_code_gbif IN ('CR', b.neighbour) AND ST_DWithin(o.geom::geography, b.geom::geography, 10000) GROUP BY b.neighbour, b.geom ORDER BY b.neighbour;-- result: g4-border · 2 rows · sha256 539a1bf4a653 neighbour | border_km | records_within_10km | coded_cr | coded_neighbour NI | 267 | 5 | 1 | 4 PA | 250 | 9 | 6 | 3
In g4.sql, with the blocks it runs after.
ST_Touchesfinds the outlines that share a border with Costa Rica's but no interior: Nicaragua and Panama. The border is where their two boundaries meet:ST_Intersectionof the boundaries returns the shared lines (and any lone points where they only touch),ST_CollectionExtract(…, 2)keeps the lines andST_LineMergejoins them into one. The land borders are 267 km (Nicaragua) and 250 km (Panama). Within 10 km of them, on either side, lie 5 and 9 records coded for Costa Rica or the neighbour; 7 of the 14 are coded for the neighbour. So close to a line, the code and the outline can each be right or wrong: a 1:50m border is generalized and can be kilometres off, and the disagreements in Spatial joins, g3, are mostly records like these. TheONclause casts every record'sgeomtogeography, so the index onoccurrences.geomcan't help, and the join reads all 40,395 records for each border (about 0.7 s); Spatial indexes, g5, shows the plan and an index that does help.
Result
Overlaying the ecoregions on Costa Rica gives seven pieces, led by the Isthmian-Atlantic moist forests (17,288 km², 33.7 %). The pieces add up to 49,908 km² of the outline's 51,303, with 2,070 km² in no ecoregion and about 675 km² counted twice: ten pairs of valid pieces overlap, 678.2 km² in all, along borders each ecoregion drew in its own place. A valid layer isn't a coverage, and an area from this overlay carries an error of a few per cent of the country. Costa Rica's land borders are 267 km (Nicaragua) and 250 km (Panama); 14 records lie within 10 km of them, half of them coded for the neighbour.
Exercise
How much of Costa Rica lies within 10 km of each land border, and which records are in that strip? Draw the strip with ST_Buffer on geography, cut it by Costa Rica's outline, and give its area, its records, how many of them GBIF codes for the neighbour, and, as a check on the buffer, how many of them ST_DWithin finds within 10 km of the border.
Show answer
g4-ex · PostgreSQL with PostGIS
WITH cr AS (SELECT geom FROM country_shapes WHERE country_code = 'CR'),
border AS (
SELECT n.country_code AS neighbour,
ST_LineMerge(ST_CollectionExtract(ST_Intersection(ST_Boundary(cr.geom), ST_Boundary(n.geom)), 2)) AS geom
FROM cr
JOIN country_shapes AS n ON n.country_code <> 'CR' AND ST_Touches(n.geom, cr.geom)
),
strip AS (
SELECT b.neighbour, b.geom AS line,
ST_Intersection(ST_Buffer(b.geom::geography, 10000)::geometry, cr.geom) AS geom
FROM border AS b, cr
)
SELECT s.neighbour,
round((ST_Area(s.geom::geography) / 1e6)::numeric) AS strip_km2,
count(o.id) AS records,
count(o.id) FILTER (WHERE o.country_code_gbif = s.neighbour) AS coded_neighbour,
count(o.id) FILTER (WHERE ST_DWithin(o.geom::geography, s.line::geography, 10000)) AS within_10km
FROM strip AS s
LEFT JOIN occurrences AS o ON ST_Covers(s.geom, o.geom)
GROUP BY s.neighbour, s.geom
ORDER BY s.neighbour;
-- result: g4-ex · 2 rows · sha256 e63983707c3d neighbour | strip_km2 | records | coded_neighbour | within_10km NI | 2673 | 2 | 1 | 2 PA | 2289 | 7 | 1 | 7
In g4.sql, with the blocks it runs after.
Check
The answer: g4-border's border lines, buffered by 10,000 m on geography, cast back to geometry and cut by the outline (ST_Intersection); then a spatial join of the records to each strip, as in g3.
The strips are 2,673 km² along Nicaragua and 2,289 km² along Panama, 5.2 % and 4.5 % of Costa Rica. A strip is close to its border's length times 10 km (2,670 and 2,500 km²): the buffer's round ends add a little, and the sea at the border's ends and the border's bends, where the buffer overlaps itself, take some away. The strips hold 2 and 7 records, against g4-border's 5 and 9 on both sides of the line: the other 3 and 2 lie outside Costa Rica's outline. One record in each strip is coded for the neighbour although it lies inside Costa Rica's outline, two of g3's 206 disagreements. within_10km equals records: the buffer's straight segments lost no record here. Had the cut been left out, the strip would reach 10 km into each neighbour as well, and a record there would count for Costa Rica.
Dialects
- PostgreSQL:
ST_Area,ST_Length,ST_Perimeter,ST_Distance,ST_DWithinandST_Buffertakegeographyand answer in metres;ST_Bufferalso takes'quad_segs=…'and, on geometry,'endcap=flat'or'side=left'for a one-sided strip.ST_Intersection,ST_Difference,ST_SymDifferenceandST_Unionoverlay, through GEOS;ST_UnionandST_Collectare aggregates too (ST_Collectonly gathers, with no merge). - DuckDB: the
spatialextension (not in this course's browser) hasST_Intersection,ST_Difference,ST_Union,ST_BufferandST_DWithinonGEOMETRY, in the units of its coordinates; for metres,ST_Transformto a projected system first, or useST_Distance_Spheroid(latitude first, g1). Without it, the browser's lab has no shapes to overlay. - SQL Server: methods on the shape:
a.STIntersection(b),a.STDifference(b),a.STUnion(b),g.STBuffer(d)anda.STDistance(b), in metres ongeography;geography::UnionAggregate(geom)dissolves rows. There is noSTDWithin: writea.STDistance(b) <= d, which a spatial index can serve when it stands alone inWHEREorON.BufferWithTolerance(d, tolerance, relative)sets how closely the curves are drawn.