Fixing H3 AttributeError After Upgrading: the v3 to v4 API Rename
Problem statement
AttributeError: module 'h3' has no attribute 'geo_to_h3'
Code that ran for years stops at its first H3 call. Nothing in your code changed; pip install pulled h3 4.x, and version 4 renamed nearly the whole Python API.
The scale of it, measured by listing the public names of both versions side by side: h3 3.7.7 exported 55 public names, and 50 of them do not exist in h3 4.5.0. geo_to_h3, k_ring, polyfill, h3_to_parent, compact, hex_area and the exception classes all went.
The same error runs the other way. Code written for v4 on a machine that still has v3 fails with:
AttributeError: module 'h3' has no attribute 'latlng_to_cell'
The rename itself is mechanical. What makes the upgrade worth doing carefully is that three functions also changed behaviour, and one of them silently changes which cells you get.
Quick answer
Confirm the version, then rename:
import h3
print(h3.__version__) # 4.5.0
cell = h3.latlng_to_cell(51.5007, -0.1246, 9) # was geo_to_h3
ring = h3.grid_disk(cell, 1) # was k_ring โ now a list
parent = h3.cell_to_parent(cell, 5) # was h3_to_parent
boundary = h3.cell_to_boundary(cell) # was h3_to_geo_boundary
print(cell, len(ring), parent)
The cell IDs themselves did not change. Measured on 10,000 random GeoNames points indexed at resolution 9 in both versions: 0 of 10,000 cells differed. Stored indexes, database keys and files written by v3 remain valid.
If you cannot migrate today, pin the old version explicitly โ h3<4 in your requirements โ so the next install does not surprise you again.
Step-by-step solution
1. Confirm which version is installed where the code runs
import h3
print(h3.__version__, h3.versions())
4.5.0 {'c': '4.5.0', 'python': '4.5.0'}
Check this in the environment that failed, not in your editor. A notebook kernel, a CI runner and a Docker image can each resolve a different version from the same unpinned h3 requirement.
2. Decide: pin or migrate
Pinning h3<4 buys time and nothing else: the last 3.x release on PyPI, 3.7.7, was published in March 2024, and every release since has been 4.x. Migrating is usually an afternoon, because the cell IDs are compatible and the renames are one-to-one. Pin only to unblock a deployment, and open the migration as a task.
3. Find every v3 call
A search for h3\. finds the calls, but a list that pairs each call with its replacement is more useful. Example 1 below does that. On a five-line sample pipeline it found six v3 calls, three of them with behaviour notes attached.
4. Apply the renames
The table below covers the functions most code uses. Every pair was checked by hasattr against both h3 3.7.7 and h3 4.5.0 โ all 40 pairs checked resolved, with no v3 name surviving in v4 except the two noted.
| v3 (h3-py 3.x) | v4 (h3-py 4.x) |
|---|---|
geo_to_h3(lat, lng, res) |
latlng_to_cell(lat, lng, res) |
h3_to_geo(h) |
cell_to_latlng(h) |
h3_to_geo_boundary(h) |
cell_to_boundary(h) |
k_ring(h, k) |
grid_disk(h, k) |
hex_ring(h, k) |
grid_ring(h, k) |
h3_distance(a, b) |
grid_distance(a, b) |
h3_line(a, b) |
grid_path_cells(a, b) |
h3_to_parent(h, res) |
cell_to_parent(h, res) |
h3_to_children(h, res) |
cell_to_children(h, res) |
h3_to_center_child(h, res) |
cell_to_center_child(h, res) |
compact(cells) |
compact_cells(cells) |
uncompact(cells, res) |
uncompact_cells(cells, res) |
polyfill(geojson, res) |
geo_to_cells(geo, res) or polygon_to_cells(LatLngPoly, res) |
h3_set_to_multi_polygon(cells) |
cells_to_h3shape(cells) or cells_to_geo(cells) |
h3_is_valid(h) |
is_valid_cell(h) |
h3_is_pentagon(h) |
is_pentagon(h) |
h3_get_resolution(h) |
get_resolution(h) |
h3_get_base_cell(h) |
get_base_cell_number(h) |
h3_indexes_are_neighbors(a, b) |
are_neighbor_cells(a, b) |
hex_area(res) |
average_hexagon_area(res) |
edge_length(res) |
average_hexagon_edge_length(res) |
exact_edge_length(e) |
edge_length(e) |
point_dist(a, b) |
great_circle_distance(a, b) |
num_hexagons(res) |
get_num_cells(res) |
get_res0_indexes() |
get_res0_cells() |
get_pentagon_indexes(res) |
get_pentagons(res) |
string_to_h3(s) / h3_to_string(i) |
str_to_int(s) / int_to_str(i) |
get_h3_unidirectional_edge(a, b) |
cells_to_directed_edge(a, b) |
The official source is the H3 core library's function name changes page. It lists the C names (geoToH3 โ latLngToCell); the Python names are the same words in snake case.
5. Fix the three behaviour changes
grid_disk returns a list. k_ring returned a set. Code that did set arithmetic on the result โ ring - {cell}, a | b โ now raises TypeError. The members are identical; wrap in set() where the code relied on it.
cell_to_boundary has no geo_json argument. Passing it raises:
TypeError: cell_to_boundary() got an unexpected keyword argument 'geo_json'
v3's geo_json=True did two things: reversed each pair to (lng, lat) and closed the ring by repeating the first vertex. Measured on one cell, v3 returned 7 vertices with it and 6 without. v4 always returns 6 open (lat, lng) pairs, so you must reverse and close the ring yourself.
polyfill is the one that changes results. See the next step.
6. Check how polyfill was called
v3's polyfill(geojson, res) had a geo_json_conformant argument that defaulted to False. By default, it read each coordinate pair as (lat, lng) โ the opposite of GeoJSON.
Measured on a small box over central London, written as real GeoJSON:
v3 polyfill(geojson, 8) 382 cells
v3 polyfill(geojson, 8, geo_json_conformant=True) 235 cells
v4 geo_to_cells(geojson, 8) 235 cells
The 382-cell answer covers a different place: the box read with its axes swapped. So a v3 codebase that relied on the default was producing wrong cells, and a correct migration changes its output. That is a fix, but it will look like a regression in any comparison with old results. Decide which of the two the old code meant before trusting either number.
7. Watch the two names that survived
cell_area exists in both versions with the same meaning. edge_length exists in both with different meanings: in v3 it took a resolution and returned the average hexagon edge; in v4 it takes a directed edge. Calling the v4 function the v3 way fails confusingly:
TypeError: int() can't convert non-string with explicit base
Rename v3 edge_length(res) to average_hexagon_edge_length(res).
8. Pin the new version and record a regression check
Write h3>=4,<5 into your requirements, and keep a small golden file of cells produced before the upgrade (Example 3). Measured across the actual upgrade, it reported 0 of 10,000 cells changed.
Code examples
Example 1 โ list every v3 call with its replacement
import re
from pathlib import Path
V3_TO_V4 = {
"geo_to_h3": "latlng_to_cell",
"h3_to_geo": "cell_to_latlng",
"h3_to_geo_boundary": "cell_to_boundary",
"k_ring": "grid_disk",
"hex_ring": "grid_ring",
"h3_distance": "grid_distance",
"h3_line": "grid_path_cells",
"h3_to_parent": "cell_to_parent",
"h3_to_children": "cell_to_children",
"h3_to_center_child": "cell_to_center_child",
"compact": "compact_cells",
"uncompact": "uncompact_cells",
"polyfill": "polygon_to_cells",
"h3_set_to_multi_polygon": "cells_to_h3shape",
"h3_is_valid": "is_valid_cell",
"h3_is_pentagon": "is_pentagon",
"h3_get_resolution": "get_resolution",
"h3_get_base_cell": "get_base_cell_number",
"h3_indexes_are_neighbors": "are_neighbor_cells",
"hex_area": "average_hexagon_area",
"edge_length": "average_hexagon_edge_length",
"exact_edge_length": "edge_length",
"point_dist": "great_circle_distance",
"num_hexagons": "get_num_cells",
"get_res0_indexes": "get_res0_cells",
"get_pentagon_indexes": "get_pentagons",
"string_to_h3": "str_to_int",
"h3_to_string": "int_to_str",
}
BEHAVIOUR = {
"k_ring": "returns a list in v4, not a set",
"h3_to_geo_boundary": "no geo_json argument; always (lat, lng), ring not closed",
"polyfill": "takes a LatLngPoly; use geo_to_cells for GeoJSON",
"edge_length": "the v4 function of this name needs a directed edge",
}
def find_v3_calls(root):
"""List every h3 v3 call in a source tree, with its v4 replacement."""
pattern = re.compile(r"\bh3\.(" + "|".join(sorted(V3_TO_V4, key=len, reverse=True)) + r")\s*\(")
hits = []
for path in Path(root).rglob("*.py"):
for lineno, line in enumerate(path.read_text(errors="ignore").splitlines(), 1):
for match in pattern.finditer(line):
old = match.group(1)
hits.append((str(path), lineno, old, V3_TO_V4[old], BEHAVIOUR.get(old, "")))
for path, lineno, old, new, note in hits:
print(f"{path}:{lineno} h3.{old} -> h3.{new}" + (f" ({note})" if note else ""))
return hits
pipeline.py:2 h3.geo_to_h3 -> h3.latlng_to_cell
pipeline.py:3 h3.k_ring -> h3.grid_disk (returns a list in v4, not a set)
pipeline.py:4 h3.h3_to_geo_boundary -> h3.cell_to_boundary (no geo_json argument; always (lat, lng), ring not closed)
pipeline.py:5 h3.polyfill -> h3.polygon_to_cells (takes a LatLngPoly; use geo_to_cells for GeoJSON)
pipeline.py:6 h3.h3_to_parent -> h3.cell_to_parent
pipeline.py:6 h3.hex_area -> h3.average_hexagon_area
The longest names are tried first so that exact_edge_length is not reported as edge_length.
Example 2 โ v4 functions that reproduce v3 results exactly
When a migration must be proven output-identical before the behaviour is corrected, reproduce the old semantics on the new library first:
import h3
def v3_polyfill(geojson, res, geo_json_conformant=False):
"""Reproduce v3 polyfill semantics on h3 v4, including its coordinate trap."""
rings = geojson["coordinates"]
if geo_json_conformant: # (lng, lat), as GeoJSON intends
return set(h3.geo_to_cells(geojson, res))
# v3's default read the pairs as (lat, lng)
outer, *holes = [[(a, b) for a, b in ring] for ring in rings]
return set(h3.polygon_to_cells(h3.LatLngPoly(outer, *holes), res))
def v3_boundary(cell, geo_json=False):
ring = h3.cell_to_boundary(cell) # (lat, lng), open ring
if not geo_json:
return ring
lnglat = tuple((lng, lat) for lat, lng in ring)
return lnglat + (lnglat[0],) # v3 closed the ring
def v3_k_ring(cell, k=1):
return set(h3.grid_disk(cell, k))
def v3_k_ring_distances(cell, k=1):
return [set(h3.grid_ring(cell, i)) for i in range(k + 1)]
Checked against real h3 3.7.7 output: v3_polyfill returned 382 and 235 cells for the two call forms, as v3 did; v3_boundary(..., geo_json=True) returned the same 7 vertices; v3_k_ring matched k_ring member for member. With these in place, swap old calls for shims, confirm nothing moved, then replace the shims with direct v4 calls one behaviour at a time.
Example 3 โ a golden-file check across the upgrade
import json
import os
import h3
def golden_cells(points, res=9, path="h3_golden.json"):
"""Record cells before the upgrade; compare after it."""
cells = [h3.latlng_to_cell(lat, lng, res) if hasattr(h3, "latlng_to_cell")
else h3.geo_to_h3(lat, lng, res) for lat, lng in points]
if not os.path.exists(path):
with open(path, "w") as f:
json.dump({"h3": h3.__version__, "res": res, "cells": cells}, f)
print(f"recorded {len(cells):,} cells with h3 {h3.__version__}")
return True
with open(path) as f:
golden = json.load(f)
changed = sum(a != b for a, b in zip(golden["cells"], cells))
print(f"h3 {golden['h3']} -> {h3.__version__}: {changed} of {len(cells):,} cells changed")
return changed == 0
Run once in the old environment and once in the new:
recorded 10,000 cells with h3 3.7.7
h3 3.7.7 -> 4.5.0: 0 of 10,000 cells changed
The function runs under both versions, which is the point: the same file proves the index is stable. Extend it with the outputs your pipeline actually depends on โ polygon fills especially.
Explanation
Why the whole API was renamed at once
H3 v4 standardised vocabulary across every language binding. Three rules explain almost every new name:
- "h3" and "hex" became "cell", because a grid of hexagons also contains twelve pentagons at every resolution.
- "geo" became "latlng", which states the argument order in the name.
- "k_ring" became "grid_disk" and "hex_ring" became "grid_ring", describing the shape rather than the implementation.
A breaking release that renames everything at once is painful for a week. The alternative โ deprecating 50 names one at a time across several releases โ would have kept the inconsistent vocabulary alive for years.
Why the cell IDs did not change
An H3 index is a 64-bit integer whose bit layout is defined by the C library: mode, resolution, base cell and one 3-bit digit per resolution. The v4 release renamed functions around that format without changing it. The 10,000-point comparison confirmed it directly: identical hexadecimal strings from both versions.
This is what makes the migration cheap. Nothing stored needs rewriting; only code does.
Why polyfill was a trap all along
GeoJSON coordinates are (longitude, latitude). H3's C API has always been (latitude, longitude). v3's Python polyfill accepted GeoJSON-shaped dictionaries but kept the C convention unless you passed geo_json_conformant=True.
A London box read with its axes swapped becomes a box at latitude โ0.2 to 0.0, longitude 51.45 to 51.55 โ open sea in the Indian Ocean, just south of the equator. A degree of longitude is longer on the ground there than at London, which is why the wrong answer had more cells, 382 against 235. v4 removed the ambiguity by splitting the function in two: geo_to_cells for GeoJSON order, polygon_to_cells with LatLngPoly for (lat, lng).
Why the exceptions changed too
v3 raised H3CellError, H3ResolutionError and friends. v4 replaced them with a hierarchy rooted in H3BaseException, with specific classes such as H3CellInvalidError and H3ResDomainError. Measured, issubclass(h3.H3CellInvalidError, ValueError) is True, so broad except ValueError blocks keep working. Code that caught the old class names by name raises AttributeError at the except line itself โ but only when an error actually reaches it. Measured, a block with except h3.H3CellError ran silently on a valid call and failed on an invalid resolution with:
AttributeError: module 'h3' has no attribute 'H3CellError'. Did you mean: 'H3ValueError'?
Tests that never exercise the error path will not catch it.
Edge cases or notes
- Exception names in
exceptclauses only fail when the error happens. Search forH3CellError,H3ResolutionError,H3EdgeErrorandH3DistanceErrorexplicitly. k_ring_distanceshas no single replacement.[set(h3.grid_ring(c, i)) for i in range(k + 1)]produced the same 1, 6 and 12 cells per ring.compact_cellsaccepts a set, measured; you do not need to convert to a list first.polygon_to_cellsrejects dictionaries withValueError: Unrecognized type: <class 'dict'>. Pass GeoJSON-like input togeo_to_cellsinstead.- The integer API moved to
h3.api.basic_int, which returned617438095025111039for the same London cell the string API writes as89194ad14c3ffff. - Resolution out of range now raises
H3ResDomainError; an unparseable string raisesValueErrorfromint(..., 16). - The DuckDB h3 extension uses v4 names (
h3_latlng_to_cell,h3_grid_disk), so SQL written against it matches the new Python vocabulary. - Transitive dependencies may pin h3 3.x. Check
pip show h3for what requires it before forcing an upgrade.
Internal links
- H3 explained: how a hexagonal grid indexes the whole Earth โ the index format that did not change
- How to assign points to H3 cells in Python โ the v4 calls in context
- How to fill a polygon with H3 cells โ
geo_to_cellsandLatLngPolyproperly - Fixing H3 polygon fill that misses cells or returns nothing โ what else goes wrong in fills
- Fixing H3 cells in the wrong place: latitude and longitude order โ the coordinate trap behind polyfill
- How to use H3 neighbours for k-ring smoothing and buffers โ
grid_diskin use - Reproducible GIS environments explained โ pinning so this does not recur
- How to set up a Python GIS environment that actually works โ where the version comes from
FAQ
Why does h3 say it has no attribute geo_to_h3?
You have h3 4.x installed and the code was written for 3.x. Version 4 renamed the API: geo_to_h3 is now latlng_to_cell, and 50 of the 55 public names in 3.7.7 no longer exist.
Do I need to recompute cells I stored with version 3?
No. Measured on 10,000 points, both versions produced identical cell IDs. The index format is defined by the C library and did not change.
What is the v4 name for k_ring?
grid_disk. It returns a list rather than a set, so wrap it in set() if your code did set arithmetic on the result.
Why does my polygon fill return a different number of cells after upgrading?
v3's polyfill read GeoJSON coordinates as latitude-first by default. On a London test box it returned 382 cells by default and 235 with geo_json_conformant=True; v4's geo_to_cells returns 235. The old default was the wrong one.
Can I keep using version 3?
Pin h3<4 to unblock a deployment, but treat it as temporary: 3.x receives no further releases, and new libraries and extensions target the v4 vocabulary.
Is there a compatibility layer?
Not in h3 itself. Small shims like Example 2 reproduce the v3 behaviour of the few functions that changed, which is enough to prove a migration output-identical before correcting it.