Building a .poly File from an OSM Admin Relation Jump to heading
The boundary you want to clip with is already in OpenStreetMap, as a relation. Turning it into the little text format the clipping tools accept is mostly assembly, plus three details that silently produce a file which clips the wrong area.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
The .poly format is a small text file: a name line, then one or more ring sections, then END. Each section has a name line of its own, a sequence of coordinate pairs one per line, and an END. A section whose name begins with ! is a hole — territory excluded from the region.
Three details decide whether the file works.
Coordinates are longitude then latitude, separated by whitespace, in decimal degrees. The order is the opposite of how the pair is usually spoken, and reversing it produces a file that clips an area somewhere else entirely.
Every ring must be closed: the last coordinate repeats the first. A ring left open is interpreted unpredictably, and some tools accept it while others produce a filter that excludes everything.
Multiple outer rings are separate sections, not one section with a gap. A country with offshore islands has one section per island, and merging them into a single sequence draws a boundary through the sea connecting them.
Runnable solution Jump to heading
from __future__ import annotations
import logging
from pathlib import Path
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.base import BaseGeometry
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.clip.polyfile")
COORD_FORMAT = " {lon:.7E} {lat:.7E}\n"
MIN_RING_POINTS = 4 # three distinct points plus the repeated closer
def _rings(geom: BaseGeometry) -> list[tuple[list[tuple[float, float]], bool]]:
"""Every ring with a flag saying whether it is a hole."""
polygons: list[Polygon]
if isinstance(geom, MultiPolygon):
polygons = list(geom.geoms)
elif isinstance(geom, Polygon):
polygons = [geom]
else:
raise TypeError(f"cannot build a boundary from {geom.geom_type}")
out: list[tuple[list[tuple[float, float]], bool]] = []
for polygon in polygons:
out.append((list(polygon.exterior.coords), False))
for interior in polygon.interiors:
out.append((list(interior.coords), True))
return out
def _closed(ring: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""Ensure the ring repeats its first coordinate at the end."""
if len(ring) < 3:
raise ValueError(f"ring has only {len(ring)} point(s)")
if ring[0] != ring[-1]:
ring = ring + [ring[0]]
if len(ring) < MIN_RING_POINTS:
raise ValueError("ring does not enclose an area")
return ring
def write_poly(geom: BaseGeometry, name: str, path: Path) -> Path:
"""Write a polygon-filter file. Coordinates are LONGITUDE then latitude."""
rings = _rings(geom)
outers = sum(1 for _, hole in rings if not hole)
holes = len(rings) - outers
if outers == 0:
raise ValueError("no outer ring; nothing would be selected")
with path.open("w", encoding="ascii") as handle:
handle.write(f"{name}\n")
outer_index = hole_index = 0
for ring, is_hole in rings:
ring = _closed(ring)
if is_hole:
hole_index += 1
# The leading '!' is the ONLY thing marking an exclusion.
handle.write(f"!{hole_index}\n")
else:
outer_index += 1
handle.write(f"{outer_index}\n")
for lon, lat in ring:
if not (-180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0):
raise ValueError(f"coordinate out of range: {lon}, {lat} "
f"— are longitude and latitude swapped?")
handle.write(COORD_FORMAT.format(lon=lon, lat=lat))
handle.write("END\n")
handle.write("END\n")
logger.info("wrote %s: %d outer ring(s), %d hole(s), %d point(s)",
path.name, outers, holes,
sum(len(r) for r, _ in rings))
return path
def sanity_check(geom: BaseGeometry, path: Path) -> None:
"""Re-read the file and compare against the source geometry's extent."""
lons: list[float] = []
lats: list[float] = []
for line in path.read_text(encoding="ascii").splitlines():
parts = line.split()
if len(parts) == 2:
try:
lon, lat = float(parts[0]), float(parts[1])
except ValueError:
continue
lons.append(lon)
lats.append(lat)
written = (min(lons), min(lats), max(lons), max(lats))
source = geom.bounds
if max(abs(a - b) for a, b in zip(written, source)) > 1e-6:
raise ValueError(f"bounds differ: file {written}, geometry {source}")
logger.info("bounds check passed: %s", written)
if __name__ == "__main__":
mainland = Polygon([(19.8, 50.0), (20.2, 50.0), (20.2, 50.2), (19.8, 50.2)],
[[(19.9, 50.05), (20.0, 50.05), (20.0, 50.1), (19.9, 50.1)]])
island = Polygon([(20.4, 50.3), (20.5, 50.3), (20.5, 50.4), (20.4, 50.4)])
region = MultiPolygon([mainland, island])
out = write_poly(region, "krakow-example", Path("krakow.poly"))
sanity_check(region, out)
Step-by-step walkthrough Jump to heading
- Emit one section per ring. Exterior rings and interior rings alike become their own sections; nothing in the format groups them, so containment is resolved by the consuming tool.
- Mark holes with a leading exclamation mark. It is the only syntax that distinguishes an exclusion, and omitting it quietly includes an enclave that should have been cut out.
- Close every ring explicitly. Shapely’s rings already repeat the first coordinate, but geometry from other sources may not, and an unclosed ring is interpreted differently by different tools.
- Reject degenerate rings. A ring with fewer than three distinct points encloses nothing, and writing it produces a filter whose behaviour nobody can predict.
- Validate the coordinate range. A latitude outside ninety degrees is the unmistakable signature of swapped coordinates, and catching it here saves a confusing empty extract later.
- Write longitude first. This is the detail that most often goes wrong, because the pair is spoken the other way round in almost every other context.
- Check the bounds after writing. Re-reading the file and comparing its extent against the source geometry catches swapped coordinates, dropped rings and formatting errors in one assertion.
Verification Jump to heading
- The bounds match the source. Comparing the written coordinates’ extent against the relation’s geometry catches swaps and dropped rings.
- Hole sections are present and marked. Count the sections beginning with an exclamation mark against the number of interior rings.
- Islands are separate sections. A region with three islands must produce at least three outer sections.
- A clip produces a plausible extract. Run
osmium extractwith the file and compare the output size against the region’s expected share. - A known point inside is retained. Pick a feature you know is inside the boundary and confirm it survives the clip.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Clipped extract is empty | Longitude and latitude swapped | Write longitude first and range-check both |
| Extract slightly too large | Hole section not marked | Prefix exclusion sections with an exclamation mark |
| Large sea areas included | Islands merged into one ring | Emit one section per outer ring |
| Behaviour differs between tools | A ring left unclosed | Repeat the first coordinate as the last |
| Tool rejects the file | Missing final END | Close each section and the file itself |
| Enclaves appear in the output | Interior rings not emitted at all | Iterate interiors as well as exteriors |
| Coordinates lose precision | Fixed decimal formatting | Use exponential notation with ample digits |
Specification reference Jump to heading
A polygon filter file begins with a name line, followed by one or more sections. Each section begins with an identifier line, contains coordinate pairs as longitude and latitude in decimal degrees one per line, and ends with
END; a section identifier prefixed with!denotes a hole. The file ends with a furtherEND. See the polygon filter file format documentation for the grammar and the hole convention.
Frequently Asked Questions Jump to heading
Why is the coordinate order longitude first?
Because the format follows the mathematical convention of x before y, and longitude is the x axis. Almost every other context — spoken directions, most user interfaces, the OSM API’s own attributes — puts latitude first, which is why this is the single most common mistake in generating these files. A range check catches it immediately, because a longitude value in a latitude position is usually outside ninety degrees.
What happens if I forget to mark a hole?
The enclave is included in the region rather than excluded from it, and the resulting extract is slightly larger than intended. This is the most dangerous of the errors here because nothing about it looks wrong: the extract clips, the size is plausible, and the extra territory only matters if somebody happens to check a feature inside the enclave. Counting marked sections against the geometry’s interior rings is the check.
Can islands go in the same section as the mainland?
No. Each section is one ring, and appending an island’s coordinates to the mainland’s draws a boundary that runs out to the island and back, enclosing the sea between them. The symptom is an extract containing large areas of water and whatever is mapped in them, which is noticeable by size but easy to attribute to the wrong cause. One outer ring, one section.
How precise do the coordinates need to be?
Precise enough that the boundary does not visibly move, which in practice means about seven decimal places or the equivalent in exponential notation. Boundaries are frequently traced from authoritative sources and their precision is meaningful; rounding to four decimal places moves the line by tens of metres, which is enough to exclude buildings that sit right on a border. The format imposes no limit, so there is no reason to economise.
Related Jump to heading
- OSM Extract Clipping & Boundaries — the parent topic and what these files are for.
- Clipping an OSM Extract with a .poly Boundary — using the file this produces.
- Handling Multipolygon Members with No Role — assembling the relation into rings first.
- Writing Overpass QL Area and Bounding Box Queries — fetching the boundary relation.
- Splitting a Planet File into Regional Extracts — where many of these files are consumed at once.
Up one level: OSM Extract Clipping & Boundaries.