Clipping an OSM Extract with a .poly Boundary Jump to heading
Cut a region out of a larger .osm.pbf using a real boundary rather than a bounding box, and end up with a file whose features are complete and whose extent you have actually checked.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Clipping asks one question of every node — is it inside the polygon — and then asks a much harder question about every way and relation that references nodes on both sides. The --strategy flag answers the second question, and the Extract Clipping & Boundary Polygons topic compares the four available answers in detail. For this procedure, use smart: it keeps relations intact, which is what makes multipolygon buildings and route relations survive the cut.
The boundary format is Osmosis .poly, a plain-text description of one or more rings. It is worth knowing precisely because it has no error detection at all.
Two properties of that format cause almost every problem people have with it. Coordinates are longitude first — the opposite of the order most people say aloud, and the same axis-order trap discussed in Coordinate Reference Systems in OSM. And there is no validation: a syntactically fine file describing a polygon in the wrong hemisphere cuts cleanly and produces nothing.
Runnable solution Jump to heading
The whole procedure, from a GeoJSON boundary to a verified extract:
#!/usr/bin/env python3
"""Clip a regional extract from a parent .osm.pbf using a simplified .poly boundary."""
from __future__ import annotations
import json
import logging
import subprocess
from pathlib import Path
from shapely.geometry import shape, MultiPolygon, Polygon
from shapely.ops import unary_union
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
SIMPLIFY_DEGREES = 0.001 # ~100 m at mid latitudes — plenty for a clip boundary
def load_boundary(geojson_path: Path) -> MultiPolygon:
"""Read a GeoJSON Feature/FeatureCollection into one simplified MultiPolygon."""
data = json.loads(geojson_path.read_text())
features = data["features"] if data.get("type") == "FeatureCollection" else [data]
geoms = [shape(f["geometry"] if "geometry" in f else f) for f in features]
merged = unary_union(geoms)
simplified = merged.simplify(SIMPLIFY_DEGREES, preserve_topology=True)
if isinstance(simplified, Polygon):
simplified = MultiPolygon([simplified])
before = sum(len(p.exterior.coords) for p in merged.geoms) if hasattr(merged, "geoms") else len(merged.exterior.coords)
after = sum(len(p.exterior.coords) for p in simplified.geoms)
logger.info("boundary simplified: %d → %d exterior vertices", before, after)
return simplified
def write_poly(mp: MultiPolygon, name: str, out: Path) -> Path:
"""Write a MultiPolygon as an Osmosis .poly file — longitude first, then latitude."""
lines: list[str] = [name]
ring_id = 0
for poly in mp.geoms:
ring_id += 1
lines.append(str(ring_id))
for lon, lat in poly.exterior.coords:
lines.append(f" {lon:.7E} {lat:.7E}")
lines.append("END")
for interior in poly.interiors: # holes are marked with a leading !
ring_id += 1
lines.append(f"!{ring_id}")
for lon, lat in interior.coords:
lines.append(f" {lon:.7E} {lat:.7E}")
lines.append("END")
lines.append("END")
out.write_text("\n".join(lines) + "\n")
logger.info("wrote %s (%d rings)", out, ring_id)
return out
def clip(parent: Path, poly: Path, output: Path, base_url: str | None = None) -> None:
"""Run osmium extract with the smart strategy, carrying replication metadata."""
cmd = [
"osmium", "extract",
"--polygon", str(poly),
"--strategy", "smart",
"--overwrite",
"-o", str(output),
]
if base_url:
cmd[-3:-3] = ["--output-header", f"osmosis_replication_base_url={base_url}"]
cmd.append(str(parent))
logger.info("running: %s", " ".join(cmd))
subprocess.run(cmd, check=True)
def verify(path: Path, boundary: MultiPolygon, tolerance_deg: float = 0.5) -> None:
"""Assert the extract is non-empty and its bbox overlaps the boundary we asked for."""
info = json.loads(subprocess.run(
["osmium", "fileinfo", "--extended", "--json", str(path)],
capture_output=True, text=True, check=True,
).stdout)
counts = info["data"]["count"]
if counts["nodes"] == 0:
raise ValueError(f"{path}: zero nodes — the boundary is probably inverted")
bbox = info["data"]["bbox"] # left, bottom, right, top
want = boundary.bounds # minx, miny, maxx, maxy
for got, expected, label in zip(bbox, want, ("left", "bottom", "right", "top")):
if abs(got - expected) > tolerance_deg + 1.0: # +1° slack for complete_ways spill
raise ValueError(f"{path}: {label} edge at {got:.3f}, expected near {expected:.3f}")
logger.info("%s verified: %d nodes, %d ways, %d relations",
path, counts["nodes"], counts["ways"], counts["relations"])
if __name__ == "__main__":
boundary = load_boundary(Path("boundaries/ireland.geojson"))
poly = write_poly(boundary, "ireland", Path("boundaries/ireland.poly"))
clip(Path("europe-latest.osm.pbf"), poly, Path("extracts/ireland.osm.pbf"),
base_url="https://planet.osm.org/replication/minute/")
verify(Path("extracts/ireland.osm.pbf"), boundary)
Step-by-step walkthrough Jump to heading
load_boundary merges every feature into a single geometry and simplifies it. The tolerance of 0.001 degrees is roughly a hundred metres, which is far finer than a clipping boundary needs and still removes the great majority of vertices from an administrative relation. preserve_topology=True guarantees the simplified ring stays valid and does not self-intersect — the defect class covered in Detecting Self-Intersecting OSM Polygons with Shapely.
write_poly emits the format exactly. Note the coordinate order in the loop: Shapely stores coordinates as (x, y), which for geographic data is (lon, lat), and that is also what .poly wants — so this is one of the few places where no swap is needed. Interior rings are written with a ! prefix so they become holes rather than additional outer rings.
clip builds the command. The --output-header insertion is what keeps the extract catchable by the diff stream later; without it, the workflow in Catching Up a Stale OSM Extract with pyosmium has no anchor to start from.
verify is the step that turns a hopeful run into a checked one. It reads the counts and the output bounding box from osmium fileinfo and asserts both. The one-degree slack allows for the smart strategy legitimately spilling past the boundary where a long way crosses it.
Verification Jump to heading
Run the script and expect three log lines: a vertex reduction of at least an order of magnitude, a ring count matching the number of separate landmasses in your boundary, and non-zero counts at the end. Then check the numbers by hand once:
osmium fileinfo --extended extracts/ireland.osm.pbf | grep -E 'Bounding box|Number of'
The bounding box should sit within about a degree of your boundary’s bounds. Node counts on the order of tens of millions for a country and hundreds of thousands for a city are the right magnitude; a count in the hundreds means the boundary is wrong, not that the region is empty.
Common errors and fixes Jump to heading
| Message or symptom | Root cause | Fix |
|---|---|---|
Zero nodes from the verifier |
Latitude and longitude swapped in the .poly |
Write longitude first; the script above already does |
Open file ... exists |
osmium refuses to overwrite |
Pass --overwrite, or remove the output first |
| Run takes over an hour on a small region | Boundary has tens of thousands of vertices | Simplify before writing the .poly |
Unknown file format on the boundary |
.poly written with a missing final END |
Every ring ends with END, and the file ends with one more |
| Output far larger than expected | Boundary rings not closed, so the polygon is degenerate | Ensure the first and last coordinate pair match |
| Extract has no replication anchor | Parent had none and no header was set | Pass --output-header with the base URL |
Specification reference Jump to heading
The
.polyformat places the polygon name on the first line, then one section per ring: a section identifier, one coordinate pair per line as longitude then latitude, andEND. A section identifier prefixed with!marks the ring as a hole. A finalENDcloses the file. Coordinates are WGS 84 degrees; the format carries no coordinate-system declaration.
Frequently Asked Questions Jump to heading
Can I use a GeoJSON boundary directly instead of converting to .poly?
Yes, on osmium-tool 1.11 and later — pass the GeoJSON file to --polygon and it is detected by extension. The conversion step above exists for two other reasons. It gives you a place to simplify, which is where the runtime saving comes from, and .poly is what most other OSM tooling accepts, so keeping the canonical boundary in that format avoids maintaining two. If your boundaries are generated fresh each run from a GIS source, skipping the conversion and simplifying in memory is perfectly reasonable.
How much should I simplify the boundary?
Enough that the vertex count drops by an order of magnitude, and no more. A tolerance of about a hundred metres takes a typical administrative relation from six figures of vertices to low thousands and moves the boundary by less than the width of the roads that cross it. Going coarser starts to cut across features you meant to include; going finer buys accuracy that a clipping operation cannot use, because the strategy already spills past the line wherever a way crosses it.
Why does the output bounding box extend past my boundary?
Because smart and complete_ways keep entire ways when any node of the way is inside. A motorway entering the region at its edge brings all of its nodes, including the ones far outside, and the header bounding box is computed from the nodes actually present. This is correct behaviour and the reason the verifier above allows a degree of slack. If you need output strictly bounded, clip the geometries in your own processing after loading them.
Should the boundary come from OSM or from an official source?
For clipping, from whichever is more stable. An OSM administrative relation is free and current but can be edited between runs, which means an extract cut last month and one cut today may not cover the same area — a difference that shows up as unexplained row-count drift. Pinning a boundary file in version control, whatever its origin, makes the cut reproducible, which usually matters more than the boundary being canonical.
Related Jump to heading
- Extract Clipping & Boundary Polygons — the topic that compares all four strategies.
- Choosing complete_ways vs smart in osmium extract — deciding between the two correct strategies.
- Splitting a Planet File into Regional Extracts — many outputs from one pass.
- Coordinate Reference Systems in OSM — why the axis order bites here.
- Extracting Metadata from OSM Planet Files — reading the header the verifier checks.
Up one level: Extract Clipping & Boundary Polygons.