Picking a UTM Zone for an OSM Extract Jump to heading
Choose the projected coordinate system for a regional extract by deriving it from the data — and recognise the extracts where UTM is the wrong answer entirely.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
UTM divides the world into sixty six-degree-wide zones, each with its own transverse Mercator projection centred on the middle of the zone. Within its zone the projection is accurate to well under a metre per kilometre; outside it, accuracy falls away quickly and quietly.
The scale factor of 0.9996 applied at the central meridian is deliberate. It makes the projection slightly too small in the middle so that it is slightly too large at the edges, halving the worst-case error compared with an unscaled projection. That is why the error in the table is negative in the centre and positive at the edge.
Nothing enforces the zone boundary. Projecting Warsaw with the zone for Berlin produces coordinates, plausible ones, wrong by metres per kilometre.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Derive the right projected CRS for an OSM extract from its own bounds."""
from __future__ import annotations
import json
import logging
import math
import subprocess
from dataclasses import dataclass
from pyproj import CRS, Transformer
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
#: Beyond this span a single transverse Mercator zone cannot cover the extract
#: without unacceptable edge distortion.
MAX_UTM_SPAN_DEGREES = 4.0
@dataclass(frozen=True)
class Bounds:
west: float
south: float
east: float
north: float
@property
def centre_lon(self) -> float:
return (self.west + self.east) / 2
@property
def centre_lat(self) -> float:
return (self.south + self.north) / 2
@property
def span_lon(self) -> float:
return self.east - self.west
def bounds_from_pbf(path: str) -> Bounds:
"""The header carries the bbox — one block read, no full pass."""
info = json.loads(subprocess.run(
["osmium", "fileinfo", "--extended", "--json", path],
capture_output=True, text=True, check=True).stdout)
box = info["data"]["bbox"]
return Bounds(west=box[0], south=box[1], east=box[2], north=box[3])
def utm_zone(lon: float) -> int:
"""Zone 1 starts at 180°W; each zone spans 6°."""
return int(math.floor((lon + 180.0) / 6.0) % 60) + 1
def utm_epsg(lon: float, lat: float) -> int:
"""326xx for the northern hemisphere, 327xx for the southern."""
return (32600 if lat >= 0 else 32700) + utm_zone(lon)
def choose_crs(bounds: Bounds) -> CRS:
"""UTM when the extract fits a zone; an equal-area projection when it does not."""
if bounds.span_lon <= MAX_UTM_SPAN_DEGREES:
epsg = utm_epsg(bounds.centre_lon, bounds.centre_lat)
crs = CRS.from_epsg(epsg)
logger.info("extract spans %.2f° — using %s (%s)",
bounds.span_lon, crs.name, f"EPSG:{epsg}")
return crs
# Too wide for one zone: a Lambert azimuthal equal-area projection centred on
# the extract distorts shape a little everywhere instead of a lot at the edges.
crs = CRS.from_proj4(
f"+proj=laea +lat_0={bounds.centre_lat:.4f} +lon_0={bounds.centre_lon:.4f} "
f"+x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs")
logger.warning("extract spans %.2f° — too wide for UTM; using LAEA centred on "
"%.3f, %.3f", bounds.span_lon, bounds.centre_lat, bounds.centre_lon)
return crs
def make_transformer(bounds: Bounds) -> Transformer:
"""always_xy keeps the argument order (lon, lat) on both sides."""
return Transformer.from_crs(CRS.from_epsg(4326), choose_crs(bounds), always_xy=True)
def scale_error_ppm(crs: CRS, lon: float, lat: float) -> float:
"""Measured, not assumed: project a 1 km east-west line and compare its length."""
fwd = Transformer.from_crs(CRS.from_epsg(4326), crs, always_xy=True)
geod = CRS.from_epsg(4326).get_geod()
delta = 0.01 # ~700 m at 52°N
x1, y1 = fwd.transform(lon, lat)
x2, y2 = fwd.transform(lon + delta, lat)
projected = math.hypot(x2 - x1, y2 - y1)
_, _, true_distance = geod.inv(lon, lat, lon + delta, lat)
return (projected / true_distance - 1.0) * 1e6
if __name__ == "__main__":
b = bounds_from_pbf("ireland.osm.pbf")
crs = choose_crs(b)
for lon in (b.west, b.centre_lon, b.east):
logger.info("scale error at lon %.2f: %+.0f ppm", lon,
scale_error_ppm(crs, lon, b.centre_lat))
Step-by-step walkthrough Jump to heading
bounds_from_pbf reads the header rather than scanning the file. The bounding box is one of the fields that comes free from the first block, so deriving a CRS costs a fraction of a second even on a planet file.
utm_zone uses the centroid of the bounds, not of a city or of the country’s capital. A country whose data extends well past its populated centre — Norway, Chile, anywhere with a long coastline — has a data centroid quite different from its perceived one, and the zone should follow the data.
choose_crs refuses to pick a zone when the extract is too wide, and this is the part worth keeping. Without the span check, a Germany-wide extract silently gets zone 32, and everything from the Rhine westward is projected across a zone boundary. The Lambert azimuthal equal-area fallback distorts shape slightly everywhere rather than badly at the edges, which is the right trade for a wide area.
scale_error_ppm measures the distortion instead of trusting the theory. It projects a short east-west segment, compares its projected length against the true geodesic distance from pyproj’s geodesic solver, and returns the error in parts per million. Running it at the extract’s western edge, centre and eastern edge is a three-line sanity check that catches a wrong zone immediately.
always_xy=True appears here for the same reason it appears everywhere on this site: without it, pyproj follows the authority axis order and the argument order silently changes meaning between CRS pairs.
Verification Jump to heading
Run the scale-error check across the extract and read the three numbers:
scale error at lon -10.48: +112 ppm
scale error at lon -8.00: -398 ppm
scale error at lon -5.30: +121 ppm
That shape — negative in the middle, positive and roughly symmetric at the edges, all within a few hundred parts per million — is what a correctly chosen zone looks like. Errors in the thousands, or strongly asymmetric errors, mean the zone is wrong or the extract is too wide.
Then verify a round trip, which catches transformer misconfiguration:
fwd = make_transformer(b)
inv = Transformer.from_crs(choose_crs(b), CRS.from_epsg(4326), always_xy=True)
for lon, lat in ((b.west, b.south), (b.centre_lon, b.centre_lat), (b.east, b.north)):
x, y = fwd.transform(lon, lat)
back_lon, back_lat = inv.transform(x, y)
assert abs(back_lon - lon) < 1e-9 and abs(back_lat - lat) < 1e-9
Sub-nanodegree agreement is expected. Centimetre-level disagreement usually means a missing datum grid; larger disagreement means the two transformers are not inverses, typically because one was built without always_xy.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Distances off by metres per kilometre | Wrong zone, or extract spans zones | Measure the scale error; widen to LAEA if needed |
| Northings around 10 000 000 in Europe | Southern-hemisphere EPSG used | 326xx north, 327xx south |
| Coordinates swapped | always_xy omitted |
Set it on every transformer |
| Areas wrong but distances fine | Conformal projection used for area | Use an equal-area projection for areas |
| Round trip off by centimetres | Datum grid missing on this host | pyproj sync, or pin the transformation |
| Zone changes between runs | Zone derived from a data centroid that moves | Pin the CRS in configuration once chosen |
Frequently Asked Questions Jump to heading
Should I use UTM or my country's national grid?
The national grid, where one exists and your consumers use it. British National Grid, RD New, Lambert-93 and their equivalents were designed for one country’s shape, usually with a better-fitting projection and a local datum, and official data is published in them. UTM’s advantage is being globally uniform, which matters when your pipeline handles many countries and nobody needs to interoperate with national datasets.
What about extracts that straddle a zone boundary?
If the span is under about four degrees, pick the zone containing the centroid and accept a slightly higher error on the far side — the measurement above shows that is still well under a metre per kilometre. If the span is larger, do not pick a zone; the honest options are a conic or equal-area projection covering the whole extract, or projecting per-zone and handling the seams, which is considerably more work than it sounds.
Does the zone matter for point-in-polygon or rendering?
No. Containment is topological and works in degrees, and rendering only needs a consistent projection, which is why web maps use Web Mercator everywhere despite its area distortion. The zone matters when you measure — lengths, areas, buffers, nearest-neighbour distances.
Should the CRS be stored with the data or recomputed?
Stored. Deriving it from the extract’s bounds is right the first time and wrong every time the bounds shift slightly, because a centroid that crosses a zone boundary silently changes the CRS between runs and makes two outputs incomparable. Derive it once, write it into the pipeline configuration, and treat a change as a deliberate migration.
Specification reference Jump to heading
UTM zone
ncovers longitudes from6n − 186to6n − 180degrees, with a central meridian at6n − 183and a scale factor of 0.9996. EPSG codes are32600 + nfor the northern hemisphere and32700 + nfor the southern. Coordinates are metres, with a false easting of 500 000 m and, in the southern hemisphere, a false northing of 10 000 000 m.
Related Jump to heading
- Coordinate Reference Systems in OSM — the topic this choice belongs to.
- Converting OSM Coordinates to a Local CRS with pyproj — applying the CRS once it is chosen.
- Extracting Metadata from OSM Planet Files — reading the bounds this derives from.
- Accelerating Point-in-Polygon Joins on OSM Data — a join that needs no projection at all.
- Extract Clipping & Boundary Polygons — how the extract’s shape gets decided in the first place.
Up one level: Coordinate Reference Systems in OSM.