Fixing H3 Cells in the Wrong Place: Latitude and Longitude Order
Problem statement
You index a set of UK towns into H3, draw the hexagons, and they appear in the Indian Ocean off the coast of Somalia. There is no error message, no warning, and every row has a valid cell ID.
h3.latlng_to_cell(-0.1246, 51.5007, 9) # longitude first, by mistake
# '897b8172ed3ffff' โ valid, and 7,491 km from London
The frustrating part is that H3 cannot tell you. Measured on 1,000,000 random GeoNames points passed with longitude and latitude reversed: 0 exceptions. That includes the 34.6% of them whose "latitude" argument was beyond ยฑ90ยฐ. h3 4.5 does not reject a latitude of 151.2; it wraps it over the pole and returns a cell at 28.8ยฐ north.
Across 43,712 populated places in Great Britain, reversing the arguments moved each cell by a median of 7,943 km. None of an 875-point sample of the swapped centres landed on land.
Quick answer
H3 takes latitude first. Almost everything around it takes longitude first. Name the arguments at the boundary:
import h3
lat, lng = 51.5007, -0.1246
cell = h3.latlng_to_cell(lat, lng, 9) # (lat, lng)
boundary = h3.cell_to_boundary(cell) # (lat, lng) pairs
xy = [(lng_, lat_) for lat_, lng_ in boundary] # shapely wants (x, y)
print(cell, xy[0])
Then check: every cell centre should be within about one edge length of the point that produced it. A swapped pair is thousands of kilometres out, so the check is unambiguous (Example 2).
Step-by-step solution
1. Look at where the cells landed, not whether they exist
Every H3 call returns a valid ID for a swapped coordinate, so validity checks pass. Look at the extent instead:
import numpy as np
centres = np.array([h3.cell_to_latlng(c) for c in cells])
print(centres[:, 0].min(), centres[:, 0].max(), centres[:, 1].min(), centres[:, 1].max())
For the swapped UK towns, the centres spanned latitude โ8.57 to 33.89 and longitude 34.58 to 60.83. The numbers look plausible in isolation, and they are the UK's longitudes sitting in the latitude slot.
2. Fix the point-indexing call
latlng_to_cell(lat, lng, res) โ the order is in the name. With pandas columns:
df["h3"] = [h3.latlng_to_cell(y, x, 9) for y, x in zip(df["lat"], df["lon"])]
With a GeoDataFrame of points, geometry.y is latitude and geometry.x is longitude:
gdf["h3"] = [h3.latlng_to_cell(p.y, p.x, 9) for p in gdf.geometry]
3. Fix cell boundaries going into shapely
cell_to_boundary returns (lat, lng) pairs. shapely.Polygon reads pairs as (x, y). Passing one straight into the other swaps every vertex.
Measured on the 7,026 resolution-6 cells covering the UK towns: built naively, the frame's total_bounds were [34.56, -8.57, 60.86, 33.95] and 0 polygons intersected the UK; reversed, the bounds were [-8.57, 34.56, 33.95, 60.86] and 7,006 did. (The remainder cover GeoNames' GB-coded places outside the Natural Earth polygon, such as the sovereign base areas on Cyprus.)
from shapely.geometry import Polygon
poly = Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(cell)])
h3.cells_to_geo(cells) avoids the question altogether: it returns GeoJSON, already (lng, lat). Measured, its first coordinate for a set of Kent cells was (0.3122, 51.3138).
4. Fix polygons going into H3
LatLngPoly takes (lat, lng) pairs; a shapely exterior ring gives (lng, lat). Feeding one to the other does not raise โ it fills a different place.
Measured on Switzerland at resolution 7: the correct ring produced 8,317 cells; the ring passed in shapely order produced 13,851 cells centred near latitude 8.2, longitude 47.3, in the Horn of Africa.
The safest fix is not to build LatLngPoly by hand at all:
cells = h3.geo_to_cells(shapely_polygon, 7) # reads GeoJSON order itself
geo_to_cells returned the same 8,317 cells from the shapely polygon directly.
5. Fix it in SQL too
DuckDB's h3 extension follows the H3 convention, h3_latlng_to_cell(lat, lng, res), while st_point is (x, y). Measured, reversing the arguments returned 887b8172edfffff with no error. When the input is a geometry, extract explicitly:
select h3_latlng_to_cell(st_y(geom), st_x(geom), 8) as h3
from places;
6. Add a check that cannot pass on swapped data
A cell's centre is never further from its point than the cell's circumradius. Assert that, and a swapped call fails immediately, however plausible its output looks. Example 2 builds it into the indexing function.
Code examples
Example 1 โ decide whether two columns are swapped
import numpy as np
def check_coordinate_order(frame, lat="lat", lng="lng", expected_bounds=None):
"""Decide whether two coordinate columns are (lat, lng), swapped, or ambiguous."""
a = frame[lat].to_numpy(dtype=float)
b = frame[lng].to_numpy(dtype=float)
report = {
"rows": len(frame),
"lat_out_of_range": int((np.abs(a) > 90).sum()),
"lng_out_of_range": int((np.abs(b) > 180).sum()),
"other_column_could_be_lat": float((np.abs(b) <= 90).mean()),
}
if expected_bounds is not None:
min_lng, min_lat, max_lng, max_lat = expected_bounds
report["inside_as_given"] = float(((b >= min_lng) & (b <= max_lng) & (a >= min_lat) & (a <= max_lat)).mean())
report["inside_if_swapped"] = float(((a >= min_lng) & (a <= max_lng) & (b >= min_lat) & (b <= max_lat)).mean())
if report["lat_out_of_range"]:
report["verdict"] = "swapped: the latitude column holds values beyond 90"
elif expected_bounds is not None and report["inside_if_swapped"] > report["inside_as_given"]:
report["verdict"] = "swapped: the data only lands in the expected area when reversed"
elif expected_bounds is None and report["other_column_could_be_lat"] == 1.0:
report["verdict"] = "ambiguous: both columns are valid latitudes - give expected_bounds"
else:
report["verdict"] = "looks like (lat, lng)"
return report
Measured on the 43,712 UK towns, with the UK's bounds supplied:
as given: inside_as_given 0.99986, inside_if_swapped 0.0 -> looks like (lat, lng)
swapped: inside_as_given 0.0, inside_if_swapped 0.99986 -> swapped: the data only lands in the expected area when reversed
swapped, no bounds given -> ambiguous: both columns are valid latitudes
Australian towns reversed were caught without bounds, because their longitudes of 113โ153 cannot be latitudes. UK towns reversed could not be, because every UK longitude is also a valid latitude. That is the whole difficulty in one comparison.
Example 2 โ index points and prove the cells landed near them
import h3
import numpy as np
def points_to_cells(frame, res, lat="lat", lng="lng", expected_bounds=None):
"""Index points after checking the order, and prove where the cells landed."""
report = check_coordinate_order(frame, lat, lng, expected_bounds)
if report["verdict"].startswith("swapped"):
raise ValueError(f"{lat}/{lng} look reversed: {report}")
cells = [h3.latlng_to_cell(y, x, res) for y, x in zip(frame[lat], frame[lng])]
centres = np.array([h3.cell_to_latlng(c) for c in cells])
offset_km = np.array([h3.great_circle_distance((y, x), tuple(c), unit="km")
for y, x, c in zip(frame[lat], frame[lng], centres)])
limit = 2 * h3.average_hexagon_edge_length(res, unit="km")
if (offset_km > limit).any():
raise AssertionError(f"{int((offset_km > limit).sum())} cells are further than {limit:.2f} km from their point")
return frame.assign(h3=cells)
The distance assertion is independent of the order check. Even if the columns are mislabelled upstream in a way the first check cannot see, a cell centre 7,000 km from its point cannot pass a limit of twice the edge length.
Example 3 โ cells to a GeoDataFrame, the right way round
import geopandas as gpd
import h3
from shapely.geometry import Polygon
def cells_to_gdf(cells):
"""cell_to_boundary gives (lat, lng); shapely wants (x, y) = (lng, lat)."""
return gpd.GeoDataFrame(
{"h3": list(cells)},
geometry=[Polygon([(lng, lat) for lat, lng in h3.cell_to_boundary(c)]) for c in cells],
crs="EPSG:4326",
)
7028 [-8.57 34.56 33.95 60.86]
The bounds read longitude, latitude, longitude, latitude โ west of Ireland to Cyprus, south of Cyprus to Shetland โ which is what a correct UK frame including Akrotiri looks like. (This run built the resolution-6 set from the parents of resolution-8 cells, which is why it holds 7,028 cells rather than step 3's 7,026: H3 children do not nest exactly inside their parents.)
Explanation
Why H3 is latitude-first
H3's C API is built around a LatLng structure, and the Python binding mirrors it. The convention is common in navigation and geodesy, where coordinates are spoken as "51.5 north, 0.12 west".
GIS software went the other way. Shapely, GeoPandas, GeoJSON, WKT and SQL constructors all use (x, y), and in a geographic CRS x is longitude. Neither is wrong; the bug lives at every point where data crosses from one convention to the other.
Why a swapped latitude does not raise
The obvious guard โ reject latitudes beyond ยฑ90ยฐ โ is not in h3 4.5. Measured: latlng_to_cell(91, 10, 5) returned a cell centred at (88.97, โ170.21), and a latitude of 180 returned one at (0.0, โ170.0). An out-of-range latitude is folded back over the pole rather than rejected, so the result is always some real cell.
Only non-finite input is rejected: nan and inf both raised H3LatLngDomainError. So the library will tell you about missing data and never about swapped data.
Why the check has to be about distance
For most of the world, a range check could never help. Of the 1,000,000 global points, 65.4% have a longitude within ยฑ90ยฐ, so reversing them produces two valid-looking numbers. The whole of Europe and Africa, and most of the Middle East, fall in that band.
What is always true is geometric: a point lies inside its cell, so its distance to the cell centre is at most the cell's circumradius. A swapped coordinate breaks that by orders of magnitude โ the smallest displacement among the 43,712 UK towns was 173 km, against an average resolution-8 edge of 0.53 km.
Why polygons fail more quietly than points
A swapped point moves. A swapped polygon becomes a different, still-valid polygon somewhere else, so polygon_to_cells happily fills it. Switzerland's reversed ring filled a region of the Horn of Africa with 13,851 cells โ more than the correct 8,317, because a degree of longitude is longer near the equator. A plausible count is no evidence of a correct fill.
Edge cases or notes
h3.geo_to_cellsandh3.cells_to_geouse GeoJSON order;LatLngPoly,latlng_to_cellandcell_to_boundaryuse(lat, lng). They live in the same module.mercantile.tile(lng, lat, zoom)is longitude-first, measured โ the opposite of H3. Pipelines that compute both tiles and cells are a common place for the swap.pygeohash.encode(lat, lng)ands2sphere.LatLng.from_degrees(lat, lng)are latitude-first, like H3.- GeoNames and many CSV exports label columns
latitudeandlongitude; trust the labels only after one check against a known place. - A CRS with axis order latitude-first (EPSG:4326 read strictly) can swap columns before H3 is involved; the ocean-points fix linked below covers that case.
- A small study area near the equator and the prime meridian makes swaps nearly undetectable by extent; test one known landmark instead.
- NaN coordinates raise
H3LatLngDomainError. Drop or report them before indexing rather than catching the exception per row.
Internal links
- My points plot in the ocean: fixing swapped latitude and longitude โ the same bug outside H3
- How to assign points to H3 cells in Python โ the indexing call done properly
- How to turn H3 cells into a GeoDataFrame of polygons โ the boundary conversion in full
- How to fill a polygon with H3 cells โ
geo_to_cellsversusLatLngPoly - Fixing H3 polygon fill that misses cells or returns nothing โ the other fill failures
- Fixing H3 hexagons that stretch across the map at the antimeridian โ the next thing that breaks at the map edge
- CRS in DuckDB: why ST_Transform moves your data to the wrong place โ axis order in SQL
- How to index data by quadkey and web map tile โ the longitude-first neighbour
FAQ
Why are my H3 hexagons in the ocean?
Latitude and longitude are reversed somewhere. H3 functions take latitude first, and shapely, GeoJSON and SQL geometry take longitude first; reversing UK towns moved their cells a median of 7,943 km, into the Indian Ocean.
Does h3 raise an error for an invalid latitude?
Not for out-of-range numbers. Measured on h3 4.5, a latitude of 151.2 or 180 returned a valid cell with no error; only NaN and infinity raised H3LatLngDomainError.
What order does cell_to_boundary return?
Latitude, longitude pairs. Reverse each pair before building a shapely polygon, or use cells_to_geo, which returns GeoJSON in longitude, latitude order.
Which H3 functions take GeoJSON order?
geo_to_cells and cells_to_geo. LatLngPoly, latlng_to_cell, cell_to_latlng and cell_to_boundary all use latitude first.
How can I detect swapped coordinates automatically?
Check the distance from each point to its cell centre; it must be under the cell's circumradius. A range check only works where longitudes exceed 90 degrees, which excluded 65.4% of a global sample.
Is the DuckDB h3 extension the same?
Yes. h3_latlng_to_cell takes latitude first, while st_point takes x then y, so extract st_y and st_x explicitly when indexing a geometry column.