Choosing the Right UTM Zone Automatically in Python

Metric analysis needs a projected CRS, and for most local-to-regional work that means the correct UTM zone — but hard-coding EPSG:32633 breaks the moment your data moves one zone east. This guide picks the right UTM zone automatically from the data's own extent, so buffers, distances, and areas stay accurate wherever the dataset lands. It is for anyone who measures on the ground and is tired of guessing EPSG codes. It sits under Coordinate Systems with PyProj in Mastering Core Geospatial Python Libraries, and pairs with Fixing PyProj CRS Transformation Errors when the transform itself misbehaves.

Why This Approach / What Goes Wrong

The Universal Transverse Mercator system divides the world into 60 zones, each six degrees of longitude wide, and each carries its own EPSG code — 326XX for the northern hemisphere, 327XX for the southern, where XX is the zone number. UTM is a conformal projection tuned to a single central meridian per zone. Accuracy is highest along that meridian and degrades as you move east or west of it, because the transverse Mercator cylinder pulls away from the ellipsoid toward the zone edges. Scale error is roughly 0.04 % at a zone boundary and drops to about -0.04 % (deliberately, via the 0.9996 scale factor) at the central meridian — small, but it compounds across long distances and large areas.

Scale factor across one six-degree UTM zone The UTM scale factor plotted from the west edge of a zone to the east edge. At the central meridian the factor is 0.9996, a deliberate shrink of about minus 0.04 percent. The curve rises through true scale roughly 1.6 degrees either side of the central meridian and reaches about plus 0.04 percent at both zone edges. The error is symmetric about the central meridian and works out at roughly 40 centimetres per kilometre at the edge. Scale factor across one six-degree zone the 0.9996 factor trades error at the central meridian for error at the edges k = 1.0000 (true scale) +0.04 % +0.04 % true scale true scale k = 0.9996 · −0.04 % west edge −3° central meridian east edge +3° Symmetric about the central meridian · 0.04 % ≈ 40 cm per kilometre measured
The projection spends the same 0.04 % of error at the zone edges that it saves at the central meridian — which is why the zone your data actually sits in measures better than the one you remember.

Two failure modes follow from this geometry. First, hard-coding a zone: a pipeline pinned to EPSG:32633 silently accumulates distortion once the survey footprint drifts into zone 32 or 34, and nothing raises — the numbers are just quietly wrong. Second, forcing a single zone onto data that spans several: a national or continental dataset projected into one UTM zone stretches badly at the far edges, and no single zone is defensible.

The fix is to let the data choose. GeoPandas exposes estimate_utm_crs(), which reads the layer's centroid, works out which six-degree band and hemisphere it falls in, and returns the matching pyproj.CRS. Under the hood it calls PyProj's query_utm_crs_info() against the PROJ database using the data's bounding box as an area of interest, so the answer is authoritative rather than a hand computation of zone = floor((lon + 180) / 6) + 1. The two traps are calling it on a layer with no CRS (it cannot estimate the target without knowing the input) and calling it on data that genuinely spans many zones, where you should switch to an equal-area projection instead of UTM. For a broader treatment of moving data between projections, see Coordinate Reference System Transformations.

Selecting a UTM zone from a dataset's extent The globe is split into sixty six-degree longitude zones. The data centroid falls in zone 33, so estimate_utm_crs returns WGS 84 / UTM zone 33N. Because the centroid is in the northern hemisphere the code carries the 326 prefix, giving EPSG:32633; southern-hemisphere data would use the 327 prefix instead. Choosing a UTM zone from the data's own extent 60 six-degree zones split the globe — the data centroid selects one 31 32 33 34 35 36 data centroid 12° 18° 24° 30° 36° estimate_utm_crs() UTM zone 33N northern hemisphere EPSG:32633 projected · metres 326 (north) + zone 33 hemisphere sets the prefix north → 326xx south → 327xx
The centroid finds its six-degree band and hemisphere: 15°E in the north lands in zone 33N, so the estimate resolves to EPSG:32633 — the 326 prefix plus the zone number.

Prerequisites

conda install -c conda-forge "geopandas=0.14.*" "pyproj=3.4.*" "shapely=2.0.*"

Installing from conda-forge keeps the compiled PROJ, GDAL, and GEOS libraries version-matched; mixing pip wheels and conda builds is the usual cause of a mismatched PROJ database that makes query_utm_crs_info() return stale codes.

Step-by-Step Implementation

1. Load the data and confirm it has a CRS — estimation needs to know the input before it can pick a target.

import geopandas as gpd

survey_points = gpd.read_file("survey_points.gpkg")
if survey_points.crs is None:
    # Assert the known input CRS — do not guess. Here the source is WGS 84 lon/lat.
    survey_points = survey_points.set_crs(epsg=4326)

print(survey_points.crs.to_epsg())   # 4326

Use set_crs() to declare a CRS the data already carries, never to_crs() — the latter reprojects coordinates and would corrupt values that are not actually in the CRS you name.

2. Let GeoPandas estimate the UTM zone from the extent.

utm_crs = survey_points.estimate_utm_crs()
print(utm_crs.to_epsg(), "-", utm_crs.name)
# 32633 - WGS 84 / UTM zone 33N

estimate_utm_crs() reprojects the bounds to geographic coordinates internally, so it works whether the input is already lon/lat or in some other projected CRS. By default it assumes the WGS 84 datum; pass datum_name="NAD83" (or another datum) if your downstream requirements demand a specific realization.

The datum_name string is matched against the PROJ authority database, not parsed, so it must be spelled the way PROJ spells it: "WGS 84" with a space, "NAD83" without one, "ETRS89", "GDA2020". An unrecognised name does not raise — it simply matches nothing, and the estimate comes back empty or falls through to a RuntimeError that says the CRS could not be estimated. The distinction matters beyond spelling, because the datum decides the EPSG code family you land in: North American data estimated against WGS 84 gives 326xx, against NAD83 gives 269xx, and the coordinates differ by one to two metres depending on the epoch. If a downstream system, a legal survey, or a database SRID expects one family, name it explicitly rather than accepting the default.

utm_nad83 = survey_points.estimate_utm_crs(datum_name="NAD83")
print(utm_nad83.to_epsg(), "-", utm_nad83.name)
# 26913 - NAD83 / UTM zone 13N

One geographic wrinkle catches people who know the grid well. The UTM grid has documented exceptions: zone 32V is widened westward so that southern Norway falls in one zone, and the Svalbard zones 31X, 33X, 35X and 37X are stretched while 32X, 34X and 36X do not exist. Those exceptions belong to the military grid definition, not to the EPSG projected CRS list — EPSG:32632 is a plain six-degree zone centred on 9°E regardless. So the estimator will return the regular zone for a Norwegian dataset, which is the right answer for a PROJ pipeline and the wrong answer if you are cross-referencing an MGRS square someone quoted from a chart. If your deliverable is a grid reference rather than a projected coordinate, compute it with an MGRS library and treat the CRS estimate as a separate concern.

3. Reproject once and do all metric work in the estimated zone.

survey_utm = survey_points.to_crs(utm_crs)

# Now buffers and lengths are in metres, correctly
survey_utm["buffer_50m"] = survey_utm.geometry.buffer(50)
survey_utm["nn_dist_m"] = survey_utm.geometry.distance(survey_utm.geometry.shift())

Reproject the whole layer up front and keep working in the projected frame; round-tripping back to EPSG:4326 between every operation is wasteful and reintroduces the axis-order pitfalls covered in Fixing PyProj CRS Transformation Errors. For buffering at scale, see Optimizing Buffer Operations for Large Datasets.

4. For a pure-PyProj pipeline or a manual audit, derive the zone yourself from the bounding box.

from pyproj import CRS
from pyproj.aoi import AreaOfInterest
from pyproj.database import query_utm_crs_info

# total_bounds is (minx, miny, maxx, maxy) — here lon/lat degrees
minx, miny, maxx, maxy = survey_points.to_crs(epsg=4326).total_bounds

utm_candidates = query_utm_crs_info(
    datum_name="WGS 84",
    area_of_interest=AreaOfInterest(
        west_lon_degree=minx,
        south_lat_degree=miny,
        east_lon_degree=maxx,
        north_lat_degree=maxy,
    ),
)
manual_crs = CRS.from_epsg(utm_candidates[0].code)
print(manual_crs.to_epsg())   # 32633

query_utm_crs_info() returns candidates sorted by how well each zone covers the area of interest, so utm_candidates[0] is the best fit. This is the same query GeoPandas runs; using it directly is useful when you have no GeoDataFrame — only a bounding box from a raster, a database extent, or an API response.

Taking [0] is safe when the extent sits inside one zone and merely convenient when it does not. A bounding box that straddles a boundary matches every zone it touches, and the ordering then reflects the database query rather than a considered ranking, so a footprint that is 95 % in zone 33 and 5 % in zone 32 can return zone 32 first. When the estimate has to be defensible, score the candidates yourself against the fraction of the extent each one actually covers.

from pyproj import CRS

def rank_utm_candidates(candidates, bbox):
    """Score each candidate zone by how much of the bbox its area of use covers."""
    minx, miny, maxx, maxy = bbox
    width = maxx - minx
    scored = []
    for info in candidates:
        w, s, e, n = info.area_of_use.bounds          # west, south, east, north
        overlap = max(0.0, min(maxx, e) - max(minx, w))
        scored.append((overlap / width if width else 1.0, info.code, info.name))
    return sorted(scored, reverse=True)

for share, code, name in rank_utm_candidates(utm_candidates, (minx, miny, maxx, maxy)):
    print(f"{share:6.1%}  EPSG:{code}  {name}")
# 100.0%  EPSG:32633  WGS 84 / UTM zone 33N
#   0.0%  EPSG:32632  WGS 84 / UTM zone 32N

best = CRS.from_epsg(rank_utm_candidates(utm_candidates, (minx, miny, maxx, maxy))[0][1])

Printing the shares is worth doing once per dataset even when you intend to trust the estimate. A split like 62 % / 38 % is not a tie to be broken; it is the dataset telling you it does not belong in a single UTM zone at all, and the decision tree below applies instead.

5. Cache the chosen CRS if the pipeline reruns. Rebuilding a Transformer per feature is a common throughput killer.

from functools import lru_cache
from pyproj import Transformer

@lru_cache(maxsize=32)
def utm_transformer(src_epsg: int, dst_epsg: int) -> Transformer:
    # always_xy=True keeps (lon, lat) / (x, y) order regardless of CRS axis definition
    return Transformer.from_crs(src_epsg, dst_epsg, always_xy=True)

tf = utm_transformer(4326, utm_crs.to_epsg())
x, y = tf.transform(15.98, 45.81)   # (lon, lat) in, (easting, northing) out

6. When the data genuinely spans zones, project per group rather than per layer. An equal-area CRS is the right answer for continental statistics, but it is the wrong answer for a task that needs true local distances — a 30-metre buffer around every substation in a national grid, say, where the buffer must be right in each region rather than consistent across all of them. The workable pattern is to assign each feature the zone its own location falls in, group by that assignment, and run the metric operation inside each group's zone. Every group is then measured in a projection that fits it, and the results are recombined in a common storage CRS at the end.

import numpy as np
import pandas as pd
import geopandas as gpd

def assign_utm_epsg(gdf: gpd.GeoDataFrame) -> "gpd.GeoSeries":
    """EPSG code of the UTM zone each feature's representative point falls in."""
    pts = gdf.to_crs(4326).representative_point()
    zone = np.floor((pts.x + 180) / 6).astype(int) + 1     # 1..60
    prefix = np.where(pts.y >= 0, 32600, 32700)
    return prefix + zone

substations = gpd.read_file("substations.gpkg")
substations["utm_epsg"] = assign_utm_epsg(substations)
print(substations["utm_epsg"].value_counts())
# 32632    4118
# 32633    2904
# 32631     877

buffered = []
for epsg, group in substations.groupby("utm_epsg"):
    local = group.to_crs(int(epsg))
    local["geometry"] = local.geometry.buffer(30)          # 30 metres, locally correct
    buffered.append(local.to_crs(substations.crs))         # back to storage CRS

exclusion_zones = gpd.GeoDataFrame(pd.concat(buffered), crs=substations.crs)

The arithmetic zone formula is appropriate here and not in step 2: it is being used to partition features, not to name the authoritative CRS for a whole dataset, and at a zone boundary either neighbour is an acceptable home for a point. Use representative_point() rather than centroid so that a multipart or crescent-shaped feature is assigned by a location guaranteed to lie on the feature itself. Features that straddle a boundary — a transmission line crossing from zone 32 into 33 — are assigned whole to one zone, which is correct as long as no feature is wide enough for the far end to leave the neighbouring zone as well.

Verification

Confirm the estimate is a metric CRS and that the east–west span does not exceed a single zone. Expected console output is shown inline as comments.

# 1. The chosen CRS must use metres, not degrees
assert survey_utm.crs.axis_info[0].unit_name == "metre"

# 2. The EPSG code must be a UTM code: 326xx (north) or 327xx (south)
assert str(survey_utm.crs.to_epsg()).startswith(("326", "327"))

# 3. Warn if the data spans more than ~6 degrees of longitude (one UTM zone)
minx, _, maxx, _ = survey_points.to_crs(epsg=4326).total_bounds
span = maxx - minx
print(f"Longitude span: {span:.2f} degrees")   # Longitude span: 1.84 degrees
assert span < 6, "Data spans multiple UTM zones — use an equal-area CRS instead"

# 4. Round-trip a known point to prove the projection is sane
back = survey_utm.geometry.iloc[0]
lonlat = gpd.GeoSeries([back], crs=survey_utm.crs).to_crs(4326).iloc[0]
print(round(lonlat.x, 2), round(lonlat.y, 2))   # 15.98 45.81

If assertion 3 fires, UTM is the wrong tool for this dataset — reach for a regional equal-area projection (see below) rather than tuning the zone.

Assertion 3 has a blind spot worth closing before you rely on it in a pipeline: total_bounds is computed on raw longitude values, so a compact dataset either side of the antimeridian — a Fiji survey, a Pacific shipping track, a Chukotka reindeer census — reports a span of nearly 360 degrees and fails a check it should pass, while a genuinely global layer reports the same thing and fails for the real reason. Distinguishing them takes the circular span rather than the arithmetic one.

import numpy as np

def circular_lon_span(lons: np.ndarray) -> float:
    """Smallest arc of longitude containing every point, antimeridian-safe."""
    lon = np.sort(np.mod(np.asarray(lons), 360.0))
    gaps = np.diff(np.append(lon, lon[0] + 360.0))
    return float(360.0 - gaps.max())     # the arc opposite the largest empty gap


pts = survey_points.to_crs(epsg=4326).representative_point()
span = circular_lon_span(pts.x.to_numpy())
print(f"Circular longitude span: {span:.2f} degrees")   # Circular longitude span: 1.84 degrees
assert span < 6, "Data really does span multiple UTM zones — use an equal-area CRS"

The function sorts the longitudes onto a circle, finds the widest empty arc, and returns what remains — which for data clustered around 179°E/179°W is a couple of degrees rather than 358. Feed the same wrapped values to estimate_utm_crs() and it will still mis-estimate, because its own bounding box has the same problem, so use this check as the gate that decides whether to trust the estimate at all rather than as a post-hoc report.

Picking a projected CRS from the longitude span A decision tree keyed on how many degrees of longitude the data covers. Under six degrees the data fits one zone, so estimate_utm_crs returns an EPSG 326xx or 327xx code in metres. Between six and thirty degrees the answer is a national grid or a Lambert Azimuthal Equal-Area CRS such as EPSG 3035. Beyond thirty degrees only an equal-area projection such as Albers or LAEA stays honest. A closing warning notes that data crossing the antimeridian or the equator must be split before any branch applies. Which projected CRS does this extent deserve? run the span assertion first — the estimate is only valid on the left branch How wide is the extent? maxx − minx, in degrees span < 6° fits inside one zone 6° to 30° regional, several zones wider than 30° continental or global estimate_utm_crs() EPSG:326xx / 327xx · metres national grid or LAEA e.g. EPSG:3035 for Europe equal-area projection Albers or LAEA, never UTM Split the data first if it crosses ±180° or the equator — a wrapped bounding box misleads every branch above
The span assertion is the branch point: one zone is a UTM job, anything wider belongs to an equal-area CRS rather than a stretched zone.

Edge Cases & Debugging

Frequently Asked Questions

When should I stop using UTM and switch to an equal-area projection? Use the span check as the trigger rather than intuition about how big the dataset "feels". Inside six degrees of longitude, a single UTM zone is the best general-purpose choice for measurement — its worst-case scale error is 0.04 %, which is below the noise in most source data. Between roughly six and thirty degrees, a national grid or a regional Lambert Azimuthal Equal-Area definition such as EPSG:3035 for Europe keeps areas honest across the whole extent. Beyond that, only an equal-area projection is defensible, because a conformal projection stretched over a continent produces area errors that dwarf anything you are trying to measure. The tell that you are in the wrong regime is that the answer changes materially when you pick a neighbouring zone.

Is estimate_utm_crs() deterministic across machines and versions? It is deterministic given the same data and the same PROJ database, and both parts matter. The estimate depends on the geometry's own bounds, so a layer that gains a new feature at its eastern edge can shift its centroid across a zone boundary and get a different answer than it did last month — correct behaviour, and the reason to pin the chosen EPSG code once a time series has started. It also depends on proj.db, so a PROJ upgrade that adds or deprecates a CRS can in principle change what comes back. Record the resolved code alongside the output rather than re-deriving it on every run, and treat a change in that code as a data event worth a log line.

Can I let the estimator pick a zone for raster data? Yes, but there is no GeoDataFrame to call the method on, so go through query_utm_crs_info() with the raster's bounds as the area of interest, exactly as in step 4. Two extra cautions apply to rasters. The bounds must be converted to geographic coordinates first, which for a raster means reprojecting the corner coordinates with Transformer.transform_bounds rather than transforming the four corners naively, since the edges bow under projection. And reprojecting a raster into the estimated zone resamples every pixel, so unlike vector data the choice has a cost in both time and radiometric fidelity — see Raster Data Handling with Rasterio.

Why not just compute the zone from the longitude with floor((lon + 180) / 6) + 1? For partitioning features that formula is fine, and step 6 uses it for exactly that. For naming the CRS of a dataset it is a worse choice than the database query, because it silently assumes the WGS 84 datum, produces an EPSG code that may not exist for the region in question, gives no signal when the extent spans a boundary, and cannot tell you which candidate zone covers the data best. The query returns authoritative CRSInfo records with names and areas of use that you can print into a log; the formula returns an integer you have to trust.

Should I reproject back to EPSG:4326 after the analysis? Only at the boundary where something else demands it — a web map, a GeoJSON deliverable, an API that specifies lon/lat. Every reprojection moves vertices and can introduce small invalidities in polygon layers, so round-tripping between geographic and projected coordinates between each operation costs both accuracy and time. Do the metric work in the projected frame, keep it there for as long as the pipeline runs, and convert once on export. If the export is a web map, convert to EPSG:4326 and let the tile layer handle the display projection rather than reprojecting to Web Mercator yourself.