Skip to content

SMC17/zig-h3

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

50 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

zig-h3

H3 hexagonal grid bindings for Zig 0.16. 211/211 tests including 10,392 property trials across 16 resolutions + 443 pentagons. Cross-validated against libh3 v4.1.0.

CI License: AGPL-3.0 Zig: 0.16 Tests: 211/211 Property trials: 10,392

Docs: tutorials · reference · bindings · internals. Or jump to BENCH.md · BAKEOFF.md · API_STABILITY.md.

Idiomatic Zig bindings for H3 v4 — Uber's hexagonal hierarchical spatial index. Wraps the official libh3 C library (v4.1.0), vendored transparently via Zig's package manager. Build it from source the first time, cached thereafter.

Architecture

flowchart LR
    A[lat/lng] --> B[zig-h3 latLngToCell]
    B --> C[H3 index]
    C --> D[cellToLatLng]
    C --> E[gridDisk neighbors]
    C --> F[polygonToCells]
    C --> G[parent/children resolution]
    H[Downstream consumers] -.-> C
    H -.includes.-> I[oceanman<br/>cable network analysis]
    click I "https://github.com/SMC17/oceanman"
Loading

Downstream consumers

  • SMC17/oceanman — submarine cable knowledge base; uses zig-h3 for cable-landing geospatial indexing alongside zig-graph for chokepoint analysis.

Status

v1.4.0 — covers all 70 H3 v4 public functions (verified by tools/coverage-check.sh, the public-symbol regression guard), spanning the full grid / edge / vertex / polygon / IJ / compact / path API. Line coverage on src/root.zig: 94.40% (219/232 lines) measured by tools/coverage.sh (kcov against a dedicated coverage-driver executable; reproducible in CI on every push).

New in v1.4.x:

  • Batch + parallel API (6 new functions: latLngToCellBatch, cellToLatLngBatch, gridDiskBatch and *Parallel variants). 8-thread parallel batch is 3.6-6× faster than libh3 scalar (see BENCH.md).
  • 5 BEYOND-libh3 functions: cellCentroidDistanceKm, cellToParentN, cellsKNearest, cellsInBbox, cellCentroidDistanceKmBatch — composing libh3 primitives into one ergonomic call.
  • 4 real language bindings via the libzig_h3.so C-ABI shim (Python, Go, Rust, Node). 48 cross-language tests pass.
  • 27 000+ executed cross-validation cases per zig build test (15× Uber's published unit-test count).
  • Downstream consumer demo: examples/zig-h3-csv/ — a runnable CLI that reads CSV of lat/lng and emits H3 cells.

BAKEOFF.md tracks the honest score vs uber/h3: 5 we lead 🟢 / 5 parity 🟡 / 3 Uber leads 🔴 (was 2/2/9 before the 2026-05-18 push). The 3 remaining 🔴 axes (production scale, ecosystem depth, governance) require distribution work to close, not engineering.

  • Lat/lng ↔ cell conversions
  • Cell boundary geometry
  • Resolution / base cell / pentagon / Class III inspection
  • Hierarchical traversal (parent, children, center child, child-position in ordered children list)
  • Grid disk traversal: gridDisk (safe), gridDiskUnsafe, gridDiskDistances, gridDiskDistancesSafe, gridDiskDistancesUnsafe, gridDisksUnsafe (multi-origin), gridRingUnsafe; plus gridDistance + neighbor check
  • Directed edges (cellsToDirectedEdge, isValidDirectedEdge, getDirectedEdgeOrigin/Destination, directedEdgeToCells, originToDirectedEdges, directedEdgeToBoundary, edgeLengthRads/Km/M)
  • Vertices (cellToVertex, cellToVertexes, vertexToLatLng, isValidVertex)
  • Polygon ↔ cells (polygonToCells, cellsToMultiPolygon, maxPolygonToCellsSize, with a LinkedMultiPolygon RAII wrapper)
  • Local IJ coordinates (cellToLocalIj, localIjToCell)
  • Grid path (gridPathCells, gridPathCellsSize)
  • Compact / uncompact (compactCells, uncompactCells, uncompactCellsSize)
  • Icosahedron faces (getIcosahedronFaces + maxFaceCount)
  • Formatting (h3 ↔ string)
  • Great-circle distances (radians, km, m)
  • Cell area (radians², km², m²) and average hexagon area / edge length
  • Resolution metadata (getNumCells, getRes0Cells, getPentagons, res0CellCount, pentagonCount)

211 tests pass with ~27,000 executed cross-validation cases per run through deterministic property corpora (122 base cells × 16 resolutions sweep, 12 pentagons × 16 resolutions sweep, 100-landmark × 16 resolutions sweep, 10 000-trial Monte Carlo on both forward + inverse geo conversions, 1000-trial roundtrip idempotency), the existing adversarial-input fuzz suite (10 000 random-u64 inputs + NaN/Inf-rejection), the 10k-trial property-based H3-invariant harness, the 2000-trial polygon round-trip property test, and the 16-operator mutation-testing harness (M07/M08 isValidCell + M15 pointInsideGeoLoop boundary gaps closed). The Python binding adds its own 10 000-trial roundtrip test against the parallel path. Coverage includes degrees↔radians roundtrip, closed-form cell-count and grid-disk-size verification, NYC / SF / Tokyo / Sydney / null-island / pole-adjacent cell resolution, boundary vertex counts, hexagonal grid disk arithmetic, k=1 neighbor and grid-distance round-trip, parent/children/center-child hierarchy (7² = 49 children at resolution-step 2), h3↔string roundtrip, San Francisco → New York City great-circle distance (4100–4200 km), res-9 cell area within published bounds, all 122 base cells valid at resolution 0, all 12 pentagons valid at every resolution, malformed-string rejection, zero-cell rejection, directed-edge origin/destination/boundary/length roundtrip on NYC res 9, hexagon-vs-pentagon edge/vertex counts (6 vs 5), polygon-to-cells on a 0.1° × 0.1° bbox at res 7, cells-to-multi-polygon on single cells and k=1 disks, local-IJ ↔ cell roundtrip on all k=1 neighbors, gridPathCells endpoint/contiguity verification, and compact/uncompact roundtrip on a full subtree.

The raw C bindings remain exposed via the raw module export as an escape hatch (e.g., for accessing helper utilities and H3Error codes directly) — but no longer hides any missing wrapper. Every H3 v4 public function has an idiomatic Zig binding.

Minimum Zig version: 0.16.0.

CI covers Linux x86_64, Linux aarch64, and macOS arm64 (native runners), plus a cross-compile sanity job for aarch64-linux-gnu and x86_64-linux-gnu. The vendored libh3 C source compiles cleanly on all three native runners. macOS-target cross-compile from a Linux host hits a Zig LLD-on-mach-o linking limitation in the example executable; the native macos-14 runner covers macOS arm64 with the system linker, so the coverage gap is in the cross-compile path from Linux to macOS, not in the macOS target itself.

Install

Add to your build.zig.zon:

.dependencies = .{
    .h3 = .{
        .url = "https://github.com/SMC17/zig-h3/archive/refs/tags/v1.1.0.tar.gz",
        .hash = "...",
    },
},

In build.zig:

const h3 = b.dependency("h3", .{
    .target = target,
    .optimize = optimize,
});
exe.root_module.addImport("h3", h3.module("h3"));

The first build downloads libh3 v4.1.0 from the official Uber/h3 GitHub release tag, verifies its hash, and compiles its 18 C source files into a static library. Subsequent builds reuse the cached static library.

Quickstart

const std = @import("std");
const h3 = @import("h3");

pub fn main() !void {
    // Statue of Liberty, resolution 9.
    const point = h3.LatLng.fromDegrees(40.6892, -74.0445);
    const cell = try h3.latLngToCell(point, 9);

    var buf: [17]u8 = undefined;
    const hex = try h3.h3ToString(cell, &buf);
    std.debug.print("cell: {s}\n", .{hex});
    std.debug.print("resolution: {d}\n", .{h3.getResolution(cell)});
    std.debug.print("base cell: {d}\n", .{h3.getBaseCellNumber(cell)});
    std.debug.print("pentagon: {}\n", .{h3.isPentagon(cell)});

    // Walk the k=1 ring.
    var ring: [7]h3.H3Index = undefined;
    try h3.gridDisk(cell, 1, &ring);
    for (ring, 0..) |neighbor, i| {
        if (neighbor == h3.H3_NULL) continue;
        std.debug.print("ring[{d}]: distance {d}\n", .{
            i,
            try h3.gridDistance(cell, neighbor),
        });
    }
}

API

pub const H3Index = u64;
pub const H3_NULL: H3Index = 0;
pub const MAX_CELL_BOUNDARY_VERTS: usize = 10;
pub const MAX_RES: i32 = 15;

pub const LatLng = extern struct {
    lat: f64,  // radians
    lng: f64,  // radians

    pub fn fromDegrees(lat_deg: f64, lng_deg: f64) LatLng;
    pub fn latDegrees(self: LatLng) f64;
    pub fn lngDegrees(self: LatLng) f64;
};

pub const CellBoundary = extern struct {
    num_verts: c_int,
    verts: [MAX_CELL_BOUNDARY_VERTS]LatLng,

    pub fn slice(self: *const CellBoundary) []const LatLng;
};

// Lat/lng ↔ cell
pub fn latLngToCell(point: LatLng, res: i32) Error!H3Index;
pub fn cellToLatLng(cell: H3Index) Error!LatLng;
pub fn cellToBoundary(cell: H3Index) Error!CellBoundary;

// Inspection
pub fn getResolution(cell: H3Index) i32;
pub fn getBaseCellNumber(cell: H3Index) i32;
pub fn isValidCell(cell: H3Index) bool;
pub fn isPentagon(cell: H3Index) bool;
pub fn isResClassIII(cell: H3Index) bool;
pub fn areNeighborCells(a: H3Index, b: H3Index) Error!bool;
pub fn maxFaceCount(cell: H3Index) Error!i32;

// Hierarchy
pub fn cellToParent(cell: H3Index, parent_res: i32) Error!H3Index;
pub fn cellToCenterChild(cell: H3Index, child_res: i32) Error!H3Index;
pub fn cellToChildrenSize(cell: H3Index, child_res: i32) Error!i64;
pub fn cellToChildren(cell: H3Index, child_res: i32, out: []H3Index) Error!void;
pub fn cellToChildPos(child: H3Index, parent_res: i32) Error!i64;
pub fn childPosToCell(child_pos: i64, parent: H3Index, child_res: i32) Error!H3Index;

// Grid traversal
pub fn maxGridDiskSize(k: i32) Error!i64;
pub fn gridDisk(origin: H3Index, k: i32, out: []H3Index) Error!void;
pub fn gridDistance(a: H3Index, b: H3Index) Error!i64;
pub fn gridPathCellsSize(start: H3Index, end: H3Index) Error!i64;
pub fn gridPathCells(start: H3Index, end: H3Index, out: []H3Index) Error!void;

// Directed edges
pub const MAX_DIRECTED_EDGES_PER_CELL: usize = 6;
pub fn cellsToDirectedEdge(origin: H3Index, destination: H3Index) Error!H3Index;
pub fn isValidDirectedEdge(edge_idx: H3Index) bool;
pub fn getDirectedEdgeOrigin(edge_idx: H3Index) Error!H3Index;
pub fn getDirectedEdgeDestination(edge_idx: H3Index) Error!H3Index;
pub fn directedEdgeToCells(edge_idx: H3Index) Error![2]H3Index;
pub fn originToDirectedEdges(origin: H3Index, out: []H3Index) Error!void;
pub fn directedEdgeToBoundary(edge_idx: H3Index) Error!CellBoundary;
pub fn edgeLengthRads(edge_idx: H3Index) Error!f64;
pub fn edgeLengthKm(edge_idx: H3Index) Error!f64;
pub fn edgeLengthM(edge_idx: H3Index) Error!f64;

// Vertices
pub const MAX_VERTEXES_PER_CELL: usize = 6;
pub fn cellToVertex(origin: H3Index, vertex_num: i32) Error!H3Index;
pub fn cellToVertexes(origin: H3Index, out: []H3Index) Error!void;
pub fn vertexToLatLng(vertex_idx: H3Index) Error!LatLng;
pub fn isValidVertex(vertex_idx: H3Index) bool;

// Polygon ↔ cells
pub const GeoLoop = extern struct { num_verts: c_int, verts: [*]LatLng };
pub const GeoPolygon = extern struct {
    geoloop: GeoLoop,
    num_holes: c_int,
    holes: ?[*]GeoLoop,
};
pub const ContainmentMode = enum(u32) {
    center, full_overlap, full_containment, overlapping_bbox,
};
pub fn maxPolygonToCellsSize(poly: *const GeoPolygon, res: i32, flags: ContainmentMode) Error!i64;
pub fn polygonToCells(poly: *const GeoPolygon, res: i32, flags: ContainmentMode, out: []H3Index) Error!void;
pub const LinkedMultiPolygon = struct { /* iterator + count + deinit */ };
pub fn cellsToMultiPolygon(cells: []const H3Index) Error!LinkedMultiPolygon;

// Local IJ coordinates
pub const CoordIJ = extern struct { i: c_int, j: c_int };
pub fn cellToLocalIj(origin: H3Index, cell: H3Index, mode: u32) Error!CoordIJ;
pub fn localIjToCell(origin: H3Index, ij: CoordIJ, mode: u32) Error!H3Index;

// Compact / uncompact
pub fn compactCells(cells: []const H3Index, out: []H3Index) Error!void;
pub fn uncompactCellsSize(cells: []const H3Index, res: i32) Error!i64;
pub fn uncompactCells(cells: []const H3Index, res: i32, out: []H3Index) Error!void;

// Icosahedron faces
pub fn getIcosahedronFaces(cell: H3Index, out: []i32) Error!void;

// Formatting
pub fn h3ToString(cell: H3Index, buf: []u8) Error![]const u8;
pub fn stringToH3(s: [:0]const u8) Error!H3Index;

// Distance / area
pub fn degsToRads(deg: f64) f64;
pub fn radsToDegs(rad: f64) f64;
pub fn greatCircleDistanceRads(a: LatLng, b: LatLng) f64;
pub fn greatCircleDistanceKm(a: LatLng, b: LatLng) f64;
pub fn greatCircleDistanceM(a: LatLng, b: LatLng) f64;
pub fn cellAreaRads2(cell: H3Index) Error!f64;
pub fn cellAreaKm2(cell: H3Index) Error!f64;
pub fn cellAreaM2(cell: H3Index) Error!f64;
pub fn hexagonAreaAvgKm2(res: i32) Error!f64;
pub fn hexagonAreaAvgM2(res: i32) Error!f64;
pub fn hexagonEdgeLengthAvgKm(res: i32) Error!f64;
pub fn hexagonEdgeLengthAvgM(res: i32) Error!f64;

// Resolution metadata
pub fn getNumCells(res: i32) Error!i64;
pub fn res0CellCount() i32;
pub fn pentagonCount() i32;
pub fn getRes0Cells(out: []H3Index) Error!void;
pub fn getPentagons(res: i32, out: []H3Index) Error!void;

// Raw C bindings escape hatch
pub const raw = @cImport({ @cInclude("h3api.h"); });

Error model

The C library returns a 32-bit error code per call. zig-h3 translates each documented code into a Zig error:

C code (H3ErrorCodes) Zig error
E_SUCCESS (0) (no error)
E_FAILED (1) Error.Failed
E_DOMAIN (2) Error.Domain
E_LATLNG_DOMAIN (3) Error.LatLngDomain
E_RES_DOMAIN (4) Error.ResolutionDomain
E_CELL_INVALID (5) Error.CellInvalid
E_DIR_EDGE_INVALID (6) Error.DirectedEdgeInvalid
E_UNDIR_EDGE_INVALID (7) Error.UndirectedEdgeInvalid
E_VERTEX_INVALID (8) Error.VertexInvalid
E_PENTAGON (9) Error.Pentagon
E_DUPLICATE_INPUT (10) Error.DuplicateInput
E_NOT_NEIGHBORS (11) Error.NotNeighbors
E_RES_MISMATCH (12) Error.ResolutionMismatch
E_MEMORY_ALLOC (13) Error.MemoryAlloc
E_MEMORY_BOUNDS (14) Error.MemoryBounds
E_OPTION_INVALID (15) Error.OptionInvalid

Design notes

Why wrap libh3 instead of pure-Zig reimplementation. The reference implementation is ~10k LOC of carefully-tuned spatial math with 16 resolutions, pentagon distortion handling, and decade-old battle-test on production systems at scale. A native rewrite is months of correctness work for no end-user benefit. Wrapping libh3 is the same choice h3-py, h3-java, and h3-go all made.

Why hash-pin v4.1.0. Zig's package manager fetches by URL+hash so the build is reproducible and the source is verified. The libh3 archive tag v4.1.0 from the official uber/h3 repository is the upstream we compile.

LatLng in radians, with degree constructors. The C API uses radians exclusively; we expose LatLng.fromDegrees so callers writing human-readable lat/lng don't have to remember the conversion. The raw lat and lng fields are still radians for direct compatibility with the C struct.

No allocation hidden in the wrapper. Functions that produce multiple cells (gridDisk, cellToChildren, getRes0Cells, getPentagons) require the caller to provide a []H3Index of sufficient size. Use the companion *Size function (e.g., maxGridDiskSize, cellToChildrenSize) or known constants (res0CellCount() == 122, pentagonCount() == 12) to size the buffer.

CellBoundary is bit-compatible with libh3.CellBoundary. The wrapper returns a CellBoundary directly (no copy through a Zig-only struct); comptime assertions in root.zig verify layout parity with the C struct.

License

This wrapper is licensed under AGPL-3.0-or-later; see LICENSE. The underlying libh3 library is Apache License 2.0 (Copyright Uber Technologies, Inc.) and is fetched from upstream at build time — its license is preserved in the downloaded archive and reproduced in LICENSE-H3-APACHE-2.0 for reference. AGPL governs the Zig wrapper code; Apache-2.0 governs the libh3 C source it links.

Tests

zig build test

166 tests, all currently passing on Zig 0.16.0. The split:

  • 47 wrapper-layer tests (libh3-backed h3.* API — including the new directed-edge, vertex, polygon, local-IJ, grid-path, and compact/uncompact families introduced in v1.1.0)
  • 117 pure-Zig tests including the 142-input cross-validation matrix (libh3 oracle vs h3.pure.* / h3.h3index.* / h3.h3decode.* / h3.grid.* / h3.hierarchy.* / h3.boundary.* / h3.localij.* / h3.vertex.* / h3.edge.* / h3.polygon.* paths)
  • 2 fuzz tests in pure.zig — 10 000 random-u64 inputs through the pure-Zig parser surface (no panics on garbage), plus NaN/Inf input rejection on pure.latLngToCell

Benchmarks

Headline result: the pure-Zig port is 0.71–0.88× of libh3 (12–29% faster) on every measured geo-conversion and gridDisk(k=3) call on i7-1065G7. Full numbers + reproduction recipe + caveats in BENCH.md.

zig build bench

Three benchmarks ship under bench/:

  • bench_latlng_to_cell.zigh3.latLngToCell at resolutions 7, 9, 11, 13, 15 over 1 M random points.
  • bench_grid_disk.zigh3.gridDisk at resolutions 7, 9, 11 with k = 1, 3, 5 over 100 K calls each. Reports ns/call and cells/sec.
  • bench_pure_vs_libh3.ziglatLngToCell / cellToLatLng / gridDisk through both the libh3 wrapper (h3.*) and the pure-Zig path (h3.h3index.* / h3.h3decode.* / h3.grid.*), side-by-side. This is the killer chart for the v0.1.0 pure-Zig port.

Each benchmark warms up, then measures with enough iterations to dampen variance over roughly one second of wall time per row. Output is parseable key=value lines. Timing uses std.os.linux.clock_gettime( .MONOTONIC, &ts) directly — std.time.Timer and std.time.nanoTimestamp were removed in Zig 0.16's stdlib reshuffle.

Representative numbers on the maintainer's workstation (Intel Core i7-1065G7 @ 1.30 GHz, Linux 7.0.3-arch1-1 x86_64, Zig 0.16.0, zig build bench with -Doptimize=ReleaseFast):

latLngToCell (libh3-wrapper path)

Resolution ns/op ops/sec
7 6 837 146 K
9 3 556 281 K
11 7 483 134 K
13 5 454 183 K
15 8 868 113 K

gridDisk (libh3-wrapper path)

Resolution k disk_size ns/op cells/sec
7 1 7 696 10.1 M
7 3 37 8 560 4.3 M
7 5 91 20 955 4.3 M
9 1 7 898 7.8 M
9 3 37 4 805 7.7 M
9 5 91 14 109 6.4 M
11 1 7 2 111 3.3 M
11 3 37 10 074 3.7 M
11 5 91 23 854 3.8 M

Pure-Zig vs libh3 (the killer chart)

Op Res libh3 ns/op pure-Zig ns/op pure/libh3
latLngToCell 7 4 954 2 981 0.60x
latLngToCell 9 5 497 4 619 0.84x
latLngToCell 11 7 795 3 333 0.43x
cellToLatLng 7 1 523 1 025 0.67x
cellToLatLng 9 2 805 1 175 0.42x
cellToLatLng 11 1 765 1 796 1.02x
gridDisk k=3 9 5 909 4 669 0.79x

Pure-Zig is at parity or faster than libh3 on every measured op at v0.1.0, with the largest wins on latLngToCell at res 11 (2.3x faster) and cellToLatLng at res 9 (2.4x faster). The cellToLatLng res-11 row is the only "essentially tied" cell — pure is 2% slower than the C reference, within run-to-run noise on this laptop.

The win is concentrated in the projection arithmetic: the pure-Zig implementation uses a flatter call graph and lets LLVM inline through the Phase 3 constant tables, whereas libh3 carries function-call overhead between latLngToCell_geoToFaceIjk_geoToHex2d_hex2dToCoordIJK plus the C ABI on every step. Both paths produce bit-identical output (validated by the 142-input cross-validation matrix); the speedup is pure codegen.

These numbers are on a busy laptop running concurrent agents; run-to-run variance is ±30% on the tighter rows (the ratio shape is stable, the absolute ns numbers fluctuate). Bring your own data on a quiet machine for steady measurements.

Related Zig libraries by the same author

See github.com/SMC17 for more.

About

Uber H3 v4 geospatial index in Zig — idiomatic wrapper + pure-Zig port. 211 tests, 27k+ cross-validation cases, 94.4% coverage, property/fuzz/mutation testing.

Topics

Resources

License

AGPL-3.0, Apache-2.0 licenses found

Licenses found

AGPL-3.0
LICENSE
Apache-2.0
LICENSE-H3-APACHE-2.0

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors