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.

Scale error of a UTM projection against distance from its central meridian A bar chart of scale error in parts per million at 52 degrees north. On the central meridian the error is minus 400 parts per million, the deliberate 0.9996 scale factor. At 1.5 degrees away it is minus 100, about ten centimetres per kilometre. At the 3-degree zone edge it is plus 400, about 40 centimetres per kilometre. One zone over at 5 degrees it is plus 1400, about 1.4 metres per kilometre. Two zones over at 8 degrees it is plus 4100, about 4.1 metres per kilometre. Distortion against distance from the central meridian transverse Mercator scale error, as parts per million, at 52°N on the central meridian −400 ppm (the 0.9996 scale factor) 1.5° away (zone edge, inner) −100 ppm ≈ 10 cm/km 3° away (zone edge) +400 ppm ≈ 40 cm/km 5° away (one zone over) +1 400 ppm ≈ 1.4 m/km 8° away (two zones over) +4 100 ppm ≈ 4.1 m/km Inside its own zone a UTM projection is accurate to under half a metre per kilometre. Two zones out it is metres, and nothing warns you.
The 0.9996 scale factor is why the error is negative in the middle: it spreads the distortion so the worst case at the zone edge is smaller.

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.

Deriving a UTM zone from an extract, with the span check that vetoes it A four-stage chain. Read the extract bounds from the PBF header, which is free. Take the centroid longitude of those bounds rather than of a chosen city. Compute the zone as the floor of longitude plus 180 divided by 6, plus one, combined with a hemisphere to give an EPSG code in the 32600 or 32700 range. Then check the span: an extract wider than about four degrees means UTM is the wrong projection family. Pick the zone from the data, not from the country extract bounds from the PBF header free, one block read centroid longitude of the bounds, not of a city the honest centre zone = ⌊(lon+180)/6⌋+1 plus a hemisphere EPSG 326xx / 327xx span check more than ~4° wide? then UTM is the wrong family The last step is the one that saves you: an extract spanning three zones cannot be projected into one of them accurately, and the fix is a different projection rather than a different zone.
Deriving the zone is three lines. Knowing when not to use a zone at all is the part that needs a decision.

Runnable solution Jump to heading

python
#!/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))
Which projection suits which extract shape A grid of five extract shapes. A city or metro area sits well inside one zone and should use UTM for that zone. A country under about four degrees wide fits one zone and can use UTM or its national grid. A country spanning three or more zones is too wide and should use Lambert conformal conic or Lambert azimuthal equal-area. A continent is far too wide and should use an equal-area projection centred on the extract. Anything global should stay in EPSG:4326 and project per query. When UTM is right, and what to use instead extract shape use a city or metro area well inside one zone UTM for that zone a country under ~4° wide fits one zone UTM, or the national grid a country spanning 3+ zones too wide Lambert conformal conic, or LAEA a continent far too wide LAEA centred on the extract anything global the whole sphere stay in EPSG:4326; project per query A national grid, where one exists, usually beats UTM for a country: it is a projection someone chose specifically for that shape.
Where a national grid exists it usually wins, because it is a projection chosen for exactly that country rather than a slice of a global scheme.

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:

code
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:

python
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 n covers longitudes from 6n − 186 to 6n − 180 degrees, with a central meridian at 6n − 183 and a scale factor of 0.9996. EPSG codes are 32600 + n for the northern hemisphere and 32700 + n for 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.

Up one level: Coordinate Reference Systems in OSM.