Fixing H3 Cells in the Wrong Place: Latitude and Longitude Order

On this page

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.7% 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])
89194ad14c3ffff (-0.1264998959528831, 51.501619722951894)

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).

Common Python and SQL spatial functions and whether each takes latitude or longitude first. who wants latitude firstcallorderh3.latlng_to_celllat, lngh3.cell_to_boundaryreturns lat, lngh3.geo_to_cells / cells_to_geolng, latpygeohash.encodelat, lngs2sphere LatLng.from_degreeslat, lngshapely Point / GeoJSON / WKTlng, latmercantile.tilelng, latDuckDB h3_latlng_to_celllat, lngDuckDB st_pointx, y = lng, latEvery boundary between the two groups is a place to swap spatialworkflow.io
H3 and S2 are latitude-first; GeoJSON, shapely, mercantile and SQL geometry constructors are longitude-first.

Look where the cells landed, then fix each call

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 (1.4032, 51.1164) — longitude first.

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 in the polygon-fill fix: 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.

Deciding whether coordinate columns are swapped, by range check and then by expected extent. are the columns reversed?Look at the column passed as latitudeany |value| > 90swapped — certainfix the callother column > 90order is righte.g. Australiaboth within ±90cannot tell by rangetest the extent65.4% of a global sample fell in the ambiguous third branch spatialworkflow.io
For data between 90° west and 90° east, no range check can tell; only knowing where the data should be can.

Proving cells landed near their points

The data used on this page

Every feature in the GeoNames allCountries dump — the UK's populated places are those with feature class P and country GB, which includes the sovereign base areas on Cyprus — and Natural Earth's 1:10m countries and land. GeoNames is edited daily, so counts drift a little.

import os
import zipfile

import pandas as pd
import requests


def fetch(url, path):
    if not os.path.exists(path):
        with requests.get(url, stream=True, timeout=600) as reply:
            reply.raise_for_status()
            with open(path, "wb") as out:
                for chunk in reply.iter_content(1 << 20):
                    out.write(chunk)


for layer in ("cultural/ne_10m_admin_0_countries", "physical/ne_10m_land"):
    fetch(f"https://naciscdn.org/naturalearth/10m/{layer}.zip", layer.split("/")[1] + ".zip")
if not os.path.exists("geonames.parquet"):                 # about 400 MB zipped
    fetch("https://download.geonames.org/export/dump/allCountries.zip", "allCountries.zip")
    with zipfile.ZipFile("allCountries.zip") as z, z.open("allCountries.txt") as f:
        pd.read_csv(f, sep="\t", header=None, usecols=[1, 4, 5, 6, 8], quoting=3,
                    names=["name", "lat", "lon", "fclass", "country"],
                    dtype={"fclass": str, "country": str}).to_parquet("geonames.parquet")
print(len(pd.read_parquet("geonames.parquet", columns=["fclass"])), "GeoNames features")
13472317 GeoNames features

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


import pandas as pd

places = pd.read_parquet("geonames.parquet", columns=["name", "lat", "lon", "fclass", "country"])
uk = places[(places.fclass == "P") & (places.country == "GB")].rename(columns={"lon": "lng"})
UK = (-8.65, 49.86, 1.77, 60.86)
reversed_ = uk.rename(columns={"lat": "lng", "lng": "lat"})
for label, frame, bounds in [("as given", uk, UK), ("swapped", reversed_, UK), ("swapped, no bounds given", reversed_, None)]:
    r = check_coordinate_order(frame, expected_bounds=bounds)
    print(f"{label:25}", {k: round(v, 5) for k, v in r.items() if k.startswith("inside")}, "->", r["verdict"])
aus = places[(places.fclass == "P") & (places.country == "AU")].rename(columns={"lat": "lng", "lon": "lat"})
print("Australian towns reversed, no bounds ->", check_coordinate_order(aus)["verdict"])
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 - give expected_bounds
Australian towns reversed, no bounds -> swapped: the latitude column holds values beyond 90

That is the UK's populated places, as given and reversed, with and without the UK's bounds.

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)


print(len(points_to_cells(uk, 9, expected_bounds=UK)), "UK places indexed and within twice an edge length")
try:
    points_to_cells(reversed_, 9, expected_bounds=UK)
except ValueError as exc:
    print("reversed:", str(exc)[:70], "...")
43712 UK places indexed and within twice an edge length
reversed: lat/lng look reversed: {'rows': 43712, 'lat_out_of_range': 0, 'lng_out ...

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",
    )


res8 = {h3.latlng_to_cell(y, x, 8) for y, x in zip(uk.lat, uk.lng)}
frame = cells_to_gdf({h3.cell_to_parent(c, 6) for c in res8})
print(len(frame), frame.total_bounds.round(2))
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.)

Example 4 — the measurements quoted on this page

import duckdb
import geopandas as gpd
from shapely.geometry import Point

london = h3.latlng_to_cell(51.5007, -0.1246, 9)
wrong = h3.latlng_to_cell(-0.1246, 51.5007, 9)
print(wrong, f"{h3.great_circle_distance(h3.cell_to_latlng(london), h3.cell_to_latlng(wrong), unit='km'):,.0f} km from London")

sample = places.dropna(subset=["lat", "lon"]).sample(1_000_000, random_state=0)
errors = 0
for y, x in zip(sample.lat, sample.lon):
    try:
        h3.latlng_to_cell(x, y, 9)
    except Exception:
        errors += 1
print(f"1,000,000 swapped GeoNames points: {errors} exceptions, {(sample.lon.abs() > 90).mean():.1%} with a 'latitude' beyond ±90")

right = np.array([h3.cell_to_latlng(h3.latlng_to_cell(y, x, 9)) for y, x in zip(uk.lat, uk.lng)])
swap = np.array([h3.cell_to_latlng(h3.latlng_to_cell(x, y, 9)) for y, x in zip(uk.lat, uk.lng)])
moved = [h3.great_circle_distance(tuple(a), tuple(b), unit="km") for a, b in zip(right, swap)]
land = gpd.read_file("ne_10m_land.zip").union_all()
probe = swap[np.random.default_rng(0).choice(len(swap), 875, replace=False)]
print(f"{len(uk):,} UK places: median move {np.median(moved):,.0f} km; swapped centres span latitude "
      f"{swap[:, 0].min():.2f}..{swap[:, 0].max():.2f}, longitude {swap[:, 1].min():.2f}..{swap[:, 1].max():.2f}; "
      f"{sum(land.contains(Point(lng, lat)) for lat, lng in probe)} of 875 on land")

res6 = {h3.latlng_to_cell(y, x, 6) for y, x in zip(uk.lat, uk.lng)}
uk_shape = gpd.read_file("ne_10m_admin_0_countries.zip").query("ADMIN == 'United Kingdom'").union_all()
naive = gpd.GeoSeries([Polygon(h3.cell_to_boundary(c)) for c in res6], crs=4326)
fixed = cells_to_gdf(res6)
print(f"{len(res6):,} resolution-6 cells: naive bounds {naive.total_bounds.round(2)}, {naive.intersects(uk_shape).sum()} touch the UK; "
      f"reversed {fixed.total_bounds.round(2)}, {fixed.intersects(uk_shape).sum()} touch it")
kent = uk[(uk.lat.between(51.1, 51.4)) & (uk.lng.between(0.3, 1.4))]
print("cells_to_geo first coordinate:", h3.cells_to_geo(sorted({h3.latlng_to_cell(y, x, 6) for y, x in zip(kent.lat, kent.lng)}))["coordinates"][0][0])
con = duckdb.connect()
con.execute("install h3 from community; load h3")
print("DuckDB, reversed:", con.execute("select h3_h3_to_string(h3_latlng_to_cell(-0.1246, 51.5007, 8))").fetchone()[0])
for y in (91, 180):
    print(f"latlng_to_cell({y}, 10, 5) centre:", [round(v, 2) for v in h3.cell_to_latlng(h3.latlng_to_cell(y, 10, 5))])
print("Sydney reversed, latlng_to_cell(151.2, -33.87, 9) centre:", [round(v, 1) for v in h3.cell_to_latlng(h3.latlng_to_cell(151.2, -33.87, 9))])
print(f"smallest UK move {min(moved):,.0f} km against an average resolution-8 edge of {h3.average_hexagon_edge_length(8, 'km'):.2f} km")
897b8172ed3ffff 7,491 km from London
1,000,000 swapped GeoNames points: 0 exceptions, 34.7% with a 'latitude' beyond ±90
43,712 UK places: median move 7,943 km; swapped centres span latitude -8.57..33.89, longitude 34.58..60.83; 0 of 875 on land
7,026 resolution-6 cells: naive bounds [34.56 -8.57 60.86 33.95], 0 touch the UK; reversed [-8.57 34.56 33.95 60.86], 7006 touch it
cells_to_geo first coordinate: (1.4032349536950721, 51.11636013219888)
DuckDB, reversed: 887b8172edfffff
latlng_to_cell(91, 10, 5) centre: [88.97, -170.21]
latlng_to_cell(180, 10, 5) centre: [0.0, -170.0]
Sydney reversed, latlng_to_cell(151.2, -33.87, 9) centre: [28.8, 146.1]
smallest UK move 173 km against an average resolution-8 edge of 0.53 km

A latitude-first library that never raises

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.

Five swapped-coordinate cases and what h3 4.5 returned for each. what h3 4.5 does with a mistakewhat you passwhat you get backUK towns, lng passed as latValid cells, 7,943 km awaylatitude 91A valid cell at 88.97° Nlatitude 180A valid cell at 0.0°Swiss ring in shapely order13,851 cells in the Horn of AfricaNaN or infinityH3LatLngDomainError — the onlyraiseOnly non-finite input raises; a distance check catches the rest spatialworkflow.io
Every realistic mistake returns a valid cell; only NaN and infinity raise.

Order conventions across H3, mercantile, geohash and S2

  • h3.geo_to_cells and h3.cells_to_geo use GeoJSON order; LatLngPoly, latlng_to_cell and cell_to_boundary use (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) and s2sphere.LatLng.from_degrees(lat, lng) are latitude-first, like H3.
  • GeoNames and many CSV exports label columns latitude and longitude; 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.

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.