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.

The structure of a .poly file with an island and an enclave A file laid out top to bottom in four parts. The first line names the region. The second part is a section for the mainland ring, opened by a number, containing one coordinate pair per line as longitude then latitude, and closed by an END. The third part is a section named with a leading exclamation mark, which marks an enclave excluded from the region. The fourth part is a separate section for an offshore island, which must be its own ring rather than being appended to the mainland. A final END closes the file. One file, one section per ring, holes marked with a bang name line the region free text ignored by tools useful to humans outer ring section 1 lon then lat one pair per line closed, then END hole !section leading exclamation an enclave excluded territory island section 2 its own ring never appended or a line through the sea The leading exclamation mark is the only syntax that distinguishes a hole, and omitting it silently includes the enclave.
Each ring is independent: nothing in the format relates them, so containment is the tool's problem rather than the file's.

Runnable solution Jump to heading

python
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

  1. 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.
  2. 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.
  3. 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.
  4. Reject degenerate rings. A ring with fewer than three distinct points encloses nothing, and writing it produces a filter whose behaviour nobody can predict.
  5. 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.
  6. 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.
  7. 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.
Four mistakes and what each one does to the clipped extract A grid of four mistakes against the symptom each produces when the file is used to clip. Swapping longitude and latitude produces an extract from somewhere else entirely, usually empty, which looks like a clipping failure rather than a file error. Omitting a hole's exclamation mark includes the enclave, producing an extract slightly too large in a way nobody notices. Merging islands into one ring draws a boundary through the sea, including large areas of water and whatever is in them. Leaving a ring unclosed produces behaviour that varies by tool, often excluding everything. Four mistakes, four different wrong extracts Symptom How it is noticed Coordinates swapped extract from elsewhere usually empty Hole not marked enclave included almost never Islands merged sea included size looks wrong Ring not closed varies by tool inconsistent Only the first mistake announces itself; the second produces a slightly larger extract that passes every plausible check.
A bounds comparison after writing catches the first and third; only reading the file catches the second.
From a boundary relation to a verified filter file Four steps. The fetch step retrieves the administrative relation and its member ways, either from an extract or from a query. The assemble step stitches the members into closed rings and determines which are exterior and which are holes, using the same geometric containment rule as any multipolygon. The write step emits one section per ring with holes marked, longitude before latitude, and every ring closed. The verify step re-reads the file, compares its extent against the source geometry and runs a trial clip to confirm the extract is plausible. Fetch, assemble, write, verify fetch relation and members extract or query assemble rings and containment same as multipolygon write one section per ring holes marked, lon first verify bounds and a trial clip catches most mistakes The second step is shared with ordinary multipolygon assembly, so a boundary relation needs no special handling until the file is written.
Only the third step is specific to this format, and it is where all four characteristic mistakes live.

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 extract with 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 further END. 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.

Up one level: OSM Extract Clipping & Boundaries.