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.

Bar chart of seconds to index one million points with DataFrame.apply, np.vectorize, a list comprehension, the integer API and DuckDB.
Measured with h3 4.5 on GeoNames coordinates; all four Python routes returned identical cells.

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.

Table comparing H3 cells stored as strings and as integers in pandas memory and Parquet size.
Integers also join and group faster, and every engine has a 64-bit integer type.

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.

Triage table of five bad coordinate inputs to latlng_to_cell and whether each raises or silently returns a cell.
H3 treats any finite pair of numbers as a position on the sphere, so it cannot tell you the order was wrong.

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 to EPSG:4326 first.
  • NaN and infinity raise H3LatLngDomainError with an empty message; None raises TypeError: 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_h3 is gone and latlng_to_cell replaces it; see the v3 to v4 rename.
  • Nullable UInt64 keeps missing rows as <NA>, while plain uint64 cannot 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.

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.