How to Assign Points to H3 Cells in Python
Problem statement
Assigning a point to an H3 cell is one function call: h3.latlng_to_cell(lat, lng, res). Doing it for a whole table goes wrong in three ways that the one-line examples do not show.
It can be slow for the wrong reason. Measured on one million GeoNames coordinates at resolution 9, DataFrame.apply took 6.65 s. A plain list comprehension doing identical work took 0.54 s, and DuckDB's H3 extension took 0.31 s including the file read. The H3 call itself is cheap; the time goes on the per-row machinery around it.
It can store the result expensively. Kept as the usual hexadecimal strings, two million cells occupy 46.0 MB in pandas. The same cells as unsigned 64-bit integers take 16.0 MB.
It can be silently wrong. Swap latitude and longitude and H3 raises nothing. It returns a valid cell somewhere else on Earth. Over 100,000 GeoNames points with swapped columns, the median cell was 4,201 km from the right one. Only non-finite input such as NaN raises an error. This guide covers a fast, integer-based, validated assignment for tables of any size.
Quick answer
import h3
import pandas as pd
df = pd.read_parquet("cities500.parquet", columns=["name", "lat", "lon"])
df["h3_9"] = [h3.latlng_to_cell(lat, lng, 9)
for lat, lng in zip(df["lat"].tolist(), df["lon"].tolist())]
print(df.head(3))
name lat lon h3_9
0 Vila 42.53176 1.56654 89396223157ffff
1 Soldeu 42.57688 1.66769 893962228bbffff
2 Sispony 42.53368 1.51613 89396223687ffff
That is 235,735 rows in 0.23 s with h3 4.5. Latitude goes first. For large tables, use h3.api.basic_int to get integers directly, and check the columns before trusting the output: nothing else will warn you that they are swapped.
Step-by-step solution
1. Check that the columns are degrees, in the right order
H3 wants WGS84 latitude and longitude in degrees, latitude first. Before indexing anything, look at the ranges:
def coordinate_report(df, lat="lat", lon="lon"):
la, lo = df[lat], df[lon]
print(f"{lat}: {la.min():.4f} .. {la.max():.4f} {lon}: {lo.min():.4f} .. {lo.max():.4f}")
print(f"missing: {la.isna().sum() + lo.isna().sum():,}")
print(f"|{lat}| > 90 (impossible): {(la.abs() > 90).sum():,}")
print(f"|{lon}| > 90 (impossible if swapped): {(lo.abs() > 90).sum():,}")
On the 13,464,117-row GeoNames dump, with the columns right and then deliberately swapped:
lat: -90.0000 .. 90.0000 lon: -180.0000 .. 180.0000
missing: 0
|lat| > 90 (impossible): 0
|lon| > 90 (impossible if swapped): 4,654,000
lat: -180.0000 .. 180.0000 lon: -90.0000 .. 90.0000
missing: 0
|lat| > 90 (impossible): 4,654,000
|lon| > 90 (impossible if swapped): 0
A "latitude" column with values beyond ยฑ90 is proof of a swap. A dataset confined to longitudes between โ90 and 90, such as most of Europe and Africa, gives no such proof, so there you must check a known location by eye. Values in the hundreds of thousands mean projected metres, which have to be transformed to EPSG:4326 first.
2. Choose the resolution for the question
The resolution sets how far each point is moved to its cell centre and how many points share a cell. At resolution 9, the 235,735 cities500 places were a median 128 m and at most 219 m from their cell centres. Choosing an H3 resolution covers how to choose. Put the level in the column name (h3_9) so tables at different levels are never joined by mistake.
3. Loop with a comprehension, never with apply
The same one million coordinates, timed five ways:
import h3
import h3.api.basic_int as hi
import numpy as np
la, lo = df["lat"].to_numpy(), df["lon"].to_numpy() # df: 1,000,000 GeoNames rows
a = [h3.latlng_to_cell(y, x, 9) for y, x in zip(la, lo)] # 0.56 s
b = [h3.latlng_to_cell(y, x, 9) for y, x in zip(la.tolist(), lo.tolist())] # 0.54 s
c = [hi.latlng_to_cell(y, x, 9) for y, x in zip(la.tolist(), lo.tolist())] # 0.41 s
d = np.vectorize(h3.latlng_to_cell, otypes=[object])(la, lo, 9) # 0.56 s
e = df.apply(lambda r: h3.latlng_to_cell(r.lat, r.lon, 9), axis=1) # 6.65 s
All five returned identical cells. np.vectorize is not vectorised: it is a Python loop with extra setup. apply(axis=1) builds a pandas Series for every row, and that is where 12 times the time goes. The integer API is fastest because it skips formatting each 64-bit index as a string.
4. Store cells as integers
import h3.api.basic_int as hi
df["h3_9"] = pd.Series([hi.latlng_to_cell(lat, lng, 9)
for lat, lng in zip(df["lat"].tolist(), df["lon"].tolist())],
dtype="uint64", index=df.index)
The string and integer forms are the same 64-bit number. h3.str_to_int("89194ad30d3ffff") is 617438095495921663, and h3.int_to_str converts back. Measured on cities500, the string column took 5.42 MB in pandas 3 (Arrow-backed str) and the integer column 1.89 MB. The largest cell identifier in GeoNames, 621,280,605,650,812,927, is below 2โถยณ, so a signed int64 column works too, which matters for engines without unsigned types.
5. Deal with missing and impossible coordinates explicitly
NaN and infinity raise. Everything else that is finite is accepted:
import h3
try:
h3.latlng_to_cell(float("nan"), -0.1278, 9)
except h3.H3LatLngDomainError as exc:
print(type(exc).__name__, repr(str(exc)))
print(h3.latlng_to_cell(-0.1278, 51.5074, 9)) # swapped: no error
H3LatLngDomainError ''
897b81723b3ffff
Uncaught, the traceback ends in a bare h3._cy.error_system.H3LatLngDomainError, because the error carries no message. It derives from ValueError (H3LatLngDomainError โ H3ValueError โ H3BaseException โ ValueError), so one NaN in ten million rows stops a plain loop at that row. Mask unusable rows before the loop rather than catching exceptions inside it. Example 1 does this.
6. Parallelise, or move the work into DuckDB
A single process indexed all 13,464,117 GeoNames points in 5.9 s. Four worker processes took 2.3 s (Example 2). DuckDB's h3 community extension adds the column while reading Parquet, and it wrote the whole table with an h3_9 column in 2.8 s on four threads (Example 3). A 300,000-row sample of DuckDB's cells matched the Python library's exactly.
7. Verify on a sample
Measure the distance from each point to its cell centre. It should never exceed about one edge length at your resolution:
import numpy as np
cc = np.array([h3.cell_to_latlng(c) for c in df["h3_9"]])
R = 6371008.8
p1, p2 = np.radians(df["lat"].to_numpy()), np.radians(cc[:, 0])
dl = np.radians(cc[:, 1] - df["lon"].to_numpy())
d = 2 * R * np.arcsin(np.sqrt(np.sin((p2 - p1) / 2) ** 2
+ np.cos(p1) * np.cos(p2) * np.sin(dl / 2) ** 2))
print(f"distance to cell centre: median {np.median(d):.0f} m, max {d.max():.0f} m")
distance to cell centre: median 128 m, max 219 m
This check catches arithmetic mistakes, not a consistent swap: swapped coordinates are close to their own, wrong, cells. For a swap, map a few known places. Fixing H3 cells in the wrong place covers that diagnosis.
Code examples
Example 1 โ a validated assignment that returns nullable integers
import h3
import h3.api.basic_int as h3i
import numpy as np
import pandas as pd
def assign_h3(df, res, lat="lat", lon="lon", as_int=True):
"""Return a Series of H3 cells; rows with unusable coordinates get <NA>."""
la = df[lat].to_numpy(dtype="float64", na_value=np.nan)
lo = df[lon].to_numpy(dtype="float64", na_value=np.nan)
impossible = np.abs(la) > 90
if impossible.mean() > 0.01:
raise ValueError(f"{impossible.sum():,} rows have |{lat}| > 90 โ are {lat} and {lon} swapped?")
usable = np.isfinite(la) & np.isfinite(lo) & ~impossible & (np.abs(lo) <= 180)
fn = h3i.latlng_to_cell if as_int else h3.latlng_to_cell
values = [fn(a, b, res) for a, b in zip(la[usable].tolist(), lo[usable].tolist())]
out = pd.Series(pd.NA, index=df.index, dtype="UInt64" if as_int else "string",
name=f"h3_{res}")
out[usable] = values
skipped = int((~usable).sum())
if skipped:
print(f"assign_h3: {skipped:,} of {len(df):,} rows had no usable coordinates")
return out
On cities500 with one NaN row and one latitude of 91.5 appended:
assign_h3: 2 of 235,737 rows had no usable coordinates
235734 619634483258195967
235735 <NA>
235736 <NA>
Name: h3_9, dtype: UInt64
It took 0.11 s. With the columns swapped, the 1% threshold stops it before any cell is computed: ValueError: 71,274 rows have |lat| > 90 โ are lat and lon swapped? A handful of bad rows passes through as missing values, while a systematic problem stops the run.
Example 2 โ the same assignment across worker processes
from multiprocessing import get_context
import h3.api.basic_int as h3i
import numpy as np
def _cells(args):
lat, lng, res = args
return np.fromiter((h3i.latlng_to_cell(a, b, res) for a, b in zip(lat.tolist(), lng.tolist())),
dtype=np.uint64, count=len(lat))
def cells_parallel(lat, lng, res, processes=4, chunk=500_000):
parts = [(lat[i:i + chunk], lng[i:i + chunk], res) for i in range(0, len(lat), chunk)]
with get_context("fork").Pool(processes) as pool:
return np.concatenate(pool.map(_cells, parts))
run 0: 13,464,117 points single 5.9s Pool(4) 2.3s identical True
run 1: 13,464,117 points single 6.0s Pool(4) 2.5s identical True
Four processes gave a 2.6ร speed-up, not 4ร. Each chunk's arrays and results are pickled across process boundaries, and that cost does not shrink as workers are added. The fork context works on Linux. On macOS and Windows, where spawn is the default, _cells must live in an importable module; see the multiprocessing fix.
Example 3 โ add the cell column while rewriting a Parquet file in DuckDB
import os
import time
import duckdb
def cells_duckdb(src, dst, res=9, threads=4):
con = duckdb.connect()
con.execute("install h3 from community")
con.execute("load h3")
con.execute(f"set threads = {threads}")
con.execute("set enable_progress_bar = false")
start = time.perf_counter()
con.execute(f"""
copy (select *, h3_latlng_to_cell(lat, lon, {res}) as h3_{res}
from read_parquet('{src}')
where lat is not null and lon is not null)
to '{dst}' (format parquet, compression zstd)
""")
rows, kind = con.execute(f"select count(*), any_value(typeof(h3_{res})) from read_parquet('{dst}')").fetchone()
print(f"{rows:,} rows, h3_{res} as {kind}, {time.perf_counter() - start:.1f}s, "
f"{os.path.getsize(dst) / 1e6:,.0f} MB")
13,464,117 rows, h3_9 as UBIGINT, 2.8s, 249 MB
The column comes out as UBIGINT, DuckDB's unsigned 64-bit integer, which is the same number Python's integer API returns. None of the table passes through Python objects. With more threads the bare computation is faster: a distinct count of all 13.46 million resolution-9 cells took 0.7 s on six threads. How to use H3 in DuckDB continues from here.
Explanation
Why apply is twelve times slower than a comprehension
latlng_to_cell is a thin wrapper around a C function, and at 0.41 s per million, the integer API costs about 410 ns per point including the Python loop. DataFrame.apply(axis=1) adds a pandas Series for every row: an index, a dtype check and attribute lookups for r.lat and r.lon. That wrapping costs about ten times more than the geometry.
.tolist() helps a little (0.56 s down to 0.54 s) because iterating a Python list of floats avoids creating a NumPy scalar per element. The larger gain is dropping string formatting with the integer API, which brings it to 0.41 s.
Why swapped coordinates do not raise
H3 converts the two numbers to radians and then to a point on the unit sphere with sines and cosines, and every finite pair of angles is some point on a sphere. Latitude 95 is simply 5ยฐ past the pole. Measured, latlng_to_cell(95.0, 10.0, 9) returned a cell centred at 84.999ยฐ N, 169.999ยฐ W, which is the same point reached by going over the pole to the opposite meridian.
Swapped coordinates are therefore different positions, not invalid ones. London's coordinates reversed land in the Indian Ocean, east of the Somali coast, 7,493 km away. Across 100,000 GeoNames points the median displacement was 4,201 km. H3 cannot detect this; only the data's ranges can, and only when they happen to exceed ยฑ90.
Why integers are the right storage
An H3 index is a 64-bit integer: one reserved high bit, 4 bits of mode, 3 more reserved bits, 4 bits of resolution, 7 bits of base cell and 3 bits for each of 15 resolution digits. The hexadecimal string is a display format of that integer. Storing strings costs 15 characters plus offsets per row, and every join and group-by then compares strings instead of machine words.
Measured, in memory the gap is nearly threefold: 46.0 MB against 16.0 MB for two million cells. On disk, Parquet's dictionary encoding and zstd narrow it to 57.4 MB against 46.2 MB for all 13.46 million rows. Keep strings for display, logs and URLs, and integers everywhere else.
Why parallelism gives less than it promises
Indexing is embarrassingly parallel, since no point depends on another, but the process pool is not free. Each 500,000-point chunk is pickled to a worker, and each result array is pickled back and concatenated. On 13.46 million points, four processes turned 5.9 s into 2.3 s. The remaining time is serialisation and the parent's own work, and more processes only add to it.
DuckDB avoids the problem by running the same C library on its own threads over columnar batches, with no Python objects in between. When the data is already in Parquet, that is the better route.
Edge cases or notes
- Latitude first.
latlng_to_cell(lat, lng, res)is the reverse of Shapely's(x, y)and of GeoJSON. A swapped call raises nothing. - Projected coordinates are accepted too.
latlng_to_cell(180000.0, 530000.0, 9)returned a valid cell. Transform toEPSG:4326first. - NaN and infinity raise
H3LatLngDomainErrorwith an empty message;NoneraisesTypeError: must be real number, not NoneType. Mask them before the loop. - Resolution 16 raises
H3ResDomainError. Valid levels are 0 to 15. - The v4 API renamed everything.
geo_to_h3is gone andlatlng_to_cellreplaces it; see the v3 to v4 rename. - Nullable
UInt64keeps missing rows as<NA>, while plainuint64cannot hold them. Use the nullable type if any row can be unusable. - Duplicate coordinates are common in real data. The busiest resolution-10 cell in GeoNames, about 0.015 kmยฒ, holds 360 features. That is a feature of the source, not a bug in the index.
Internal links
- Fixing H3 cells in the wrong place: latitude and longitude order โ diagnosing the swap this guide can only partly detect
- Choosing an H3 resolution โ what the
resargument does to your data - H3 explained: how a hexagonal grid indexes the whole Earth โ the bit layout behind the 64-bit integer
- How to aggregate points into H3 hexagons and map them โ the usual next step
- How to join two point datasets on an H3 index โ using the cell column as a join key
- How to use H3 in DuckDB for grid aggregation at scale โ the SQL route from Example 3
- GeoPandas .apply() takes hours: how to fix it โ the same per-row cost in geometry work
- My points plot in the ocean: fixing swapped latitude and longitude โ the swap outside H3
FAQ
How do I convert latitude and longitude to an H3 cell in Python?
Call h3.latlng_to_cell(lat, lng, res) with latitude first, inside a list comprehension over the two columns. With h3 4.5 that indexed 235,735 points in 0.23 s.
Why is DataFrame.apply so slow for H3?
It constructs a pandas Series for every row before H3 is even called. On one million points it took 6.65 s, against 0.54 s for a comprehension doing the same work.
Should I store H3 cells as strings or integers?
Integers. They are the same 64-bit value. Two million cells took 16.0 MB as integers against 46.0 MB as strings, and integer joins and group-bys compare machine words rather than text.
Does H3 raise an error if latitude and longitude are swapped?
No. Any finite pair of numbers is a valid position on the sphere, so you get a real cell in the wrong place, a median of 4,201 km away in a GeoNames test. Only NaN and infinity raise.
How do I index tens of millions of points quickly?
Use DuckDB's h3 extension on the Parquet file, or split the arrays across worker processes. All 13.46 million GeoNames points took 2.8 s to rewrite with a cell column in DuckDB and 2.3 s with four Python processes.
What happens to rows with missing coordinates?
latlng_to_cell raises H3LatLngDomainError on NaN, which stops a loop. Mask those rows first and store the result in a nullable integer column so they stay as missing values.