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.

Table of the three naming rules behind the H3 v4 rename, with an example of each.
Once the three rules are clear, most new names can be guessed before looking them up.

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.

Triage table of four H3 v3 calls whose behaviour changed in v4 and the fix for each.
The renames fail loudly; these four are the ones that can pass a quick test and still be wrong.

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.

Bar chart of cell counts from v3 polyfill with its default, v3 with geo_json_conformant, and v4 geo_to_cells.
A faithful migration of the default call reproduces a bug; a correct one changes the output.

Edge cases or notes

  • Exception names in except clauses only fail when the error happens. Search for H3CellError, H3ResolutionError, H3EdgeError and H3DistanceError explicitly.
  • k_ring_distances has 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_cells accepts a set, measured; you do not need to convert to a list first.
  • polygon_to_cells rejects dictionaries with ValueError: Unrecognized type: <class 'dict'>. Pass GeoJSON-like input to geo_to_cells instead.
  • The integer API moved to h3.api.basic_int, which returned 617438095025111039 for the same London cell the string API writes as 89194ad14c3ffff.
  • Resolution out of range now raises H3ResDomainError; an unparseable string raises ValueError from int(..., 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 h3 for what requires it before forcing an upgrade.

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.