ExH3o
Elixir bindings for h3o, a Rust implementation of Uber's H3 geospatial indexing system.
H3 maps geographic coordinates onto a hierarchical grid of hexagonal cells at 16 resolutions. It's widely used for spatial indexing, aggregation, and analysis of location data.
Features
- Full coverage of common H3 v4 operations: cell indexing, geo conversion, k-rings, children/parent hierarchy, neighbors, edges, polygon fill, and compaction.
-
Native-speed lookups via a thin C NIF that links against a Rust
staticlib wrapping
h3o. No Rust toolchain is required at runtime, only at build time. -
Bare return values,
ArgumentErroron invalid input. Matches the convention used by the reference erlang-h3 library, so code that mixes the two doesn't change shape. -
Graceful shutdown for in-flight dirty NIFs via
ERL_NIF_OPT_DELAY_HALT.
Performance vs erlang-h3
erlang-h3 is the established H3 binding for the Elixir ecosystem. ExH3o is a drop-in alternative that runs faster than erlang-h3 on most operations in the H3 v4 API surface.
Benchmarks below were measured on an Apple M1 Pro running Elixir
1.19.5 / OTP 28, head-to-head under Benchee with 5 seconds of
measurement per scenario. Times are per-call averages. The Speedup
column is the ratio erlang-h3 / ex_h3o; values greater than 1.0
mean ex_h3o is faster and values less than 1.0 mean ex_h3o is
slower. See Reproducing below to run the same
suite on your own hardware.
polyfill/2
| Polygon | Resolution | erlang-h3 | ex_h3o | Speedup |
|---|---|---|---|---|
| ~1 SF block | 7 | 31.03 µs | 6.34 µs | 4.89× |
| ~1 SF block | 9 | 53.31 µs | 18.68 µs | 2.85× |
| ~1 SF block | 11 | 362.45 µs | 198.0 µs | 1.83× |
| ~1 km² urban | 7 | 21.30 µs | 8.24 µs | 2.59× |
| ~1 km² urban | 9 | 97.91 µs | 69.41 µs | 1.41× |
| ~1 km² urban | 11 | 1.92 ms | 0.87 ms | 2.21× |
| ~100 km² region | 5 | 53.91 µs | 23.41 µs | 2.30× |
| ~100 km² region | 7 | 473.80 µs | 273.6 µs | 1.73× |
| ~100 km² region | 8 | 2.54 ms | 1.24 ms | 2.05× |
Single-cell operations
| Operation | erlang-h3 | ex_h3o | Speedup |
|---|---|---|---|
is_valid/1 (valid) | 46.95 ns | 15.64 ns | 3.00× |
get_base_cell/1 | 52.47 ns | 20.81 ns | 2.52× |
get_resolution/1 | 50.59 ns | 21.08 ns | 2.40× |
from_string/1 | 139.06 ns | 70.02 ns | 1.99× |
is_pentagon/1 | 30.47 ns | 20.75 ns | 1.47× |
to_geo/1 | 284 ns | 248 ns | 1.14× |
from_geo/2 | 320 ns | 325 ns | 0.98× |
to_string/1 | 119 ns | 140 ns | 0.85× |
Grid and hierarchy
| Operation | Input | erlang-h3 | ex_h3o | Speedup |
|---|---|---|---|---|
k_ring/2 | k=1 (7 cells) | 230 ns | 139 ns | 1.66× |
k_ring/2 | k=5 (91 cells) | 1.82 µs | 1.40 µs | 1.30× |
k_ring/2 | k=10 (331 cells) | 6.30 µs | 5.46 µs | 1.15× |
k_ring/2 | k=20 (1,261 cells) | 27.76 µs | 23.92 µs | 1.16× |
k_ring/2 | k=50 (7,651 cells) | 162.37 µs | 163.19 µs | 0.99× |
k_ring_distances/2 | k=1 | 309 ns | 173 ns | 1.79× |
k_ring_distances/2 | k=5 | 2.08 µs | 1.89 µs | 1.10× |
k_ring_distances/2 | k=10 | 7.66 µs | 7.26 µs | 1.06× |
children/2 | +1 level (7) | 180 ns | 134 ns | 1.34× |
children/2 | +2 levels (49) | 449 ns | 415 ns | 1.08× |
children/2 | +3 levels (343) | 2.72 µs | 2.74 µs | 0.99× |
Set operations
compact/1 and uncompact/2 are slower than erlang-h3 on small
inputs (roughly 0.5× and 0.75× speedup in the benchmarks below).
If your workload leans heavily on these operations, benchmark both
libraries against representative inputs before choosing.
Reproducing
mix run bench/single_cell.exs
mix run bench/collections.exs
mix run bench/polyfill.exsEach script prints a head-to-head comparison table for one category of operations.
Installation
Add ex_h3o to your mix.exs dependencies:
def deps do
[
{:ex_h3o, "~> 0.1.0"}
]
end
Then run mix deps.get and mix compile. On supported targets the
compile step downloads a precompiled NIF from the GitHub Release
matching the package version. On other targets, or when forced via
EX_H3O_BUILD=true, it builds the Rust staticlib locally via
cargo and links it into priv/ex_h3o_nif.so.
Precompiled targets
Precompiled NIFs are published to GitHub Releases for:
-
macOS ARM64 (
aarch64-apple-darwin) -
macOS x86-64 (
x86_64-apple-darwin) -
Linux x86-64 glibc (
x86_64-unknown-linux-gnu)
On these targets no Rust toolchain is required at install time.
Build requirements
Source builds are used for any target outside the list above, and
can be forced on supported targets by setting EX_H3O_BUILD=true
before mix compile. Source builds require:
- Elixir 1.18+ / OTP 26+ (OTP 28 recommended)
-
A C compiler (
cc) available onPATH -
A Rust toolchain (
cargo) available onPATH - macOS and Linux are supported. Windows is not.
Usage
# Convert a {lat, lng} coordinate to an H3 cell at a given resolution
cell = ExH3o.from_geo({37.7749, -122.4194}, 9)
# => 617700169958686719
# Round-trip back to coordinates
ExH3o.to_geo(cell)
# => {37.77490199, -122.41942334}
# Grid queries
ExH3o.k_ring(cell, 2) # cells within distance 2
ExH3o.children(cell, 11) # child cells at a finer resolution
ExH3o.parent(cell, 7) # parent cell at a coarser resolution
ExH3o.is_pentagon(cell) # pentagon check
ExH3o.grid_distance(cell_a, cell_b)
# Polygon fill
polygon = [
{37.770, -122.420},
{37.770, -122.410},
{37.780, -122.410},
{37.780, -122.420},
{37.770, -122.420}
]
ExH3o.polyfill(polygon, 9)Invalid input raises rather than returning an error tuple:
ExH3o.from_geo({900.0, 0.0}, 9)
# ** (ArgumentError) argument error
ExH3o.get_resolution(0)
# ** (ArgumentError) argument error
If you'd rather get {:ok, _} | {:error, _} back, wrap the call
site in try/rescue.
See the module docs for the complete API: https://hexdocs.pm/ex_h3o.
Development
# Fetch deps
mix deps.get
# Compile (builds the Rust staticlib + C NIF)
mix compile
# Run the test suite
mix test
# Formatter / linter / type checker
mix format --check-formatted
mix credo --strict
mix dialyzerBenchmarks
Comparative benchmarks against erlang-h3 live under bench/:
mix run bench/single_cell.exs # scalar per-call ops
mix run bench/collections.exs # k_ring, children, compact, uncompact
mix run bench/polyfill.exs # polygon fill across sizes
mix run bench/gc_deep_dive.exs # side-by-side GC pressure measurement
mix run bench/stress.exs # concurrent-load stress harnessContributing
Contributions are welcome. Please open an issue before starting any sizable change so we can discuss direction.
Before submitting a PR:
- Add or update tests for any behaviour change.
-
Run
mix format,mix credo --strict, andmix testlocally. - Keep commits focused and write a clear commit message.
Bug reports with a minimal reproduction are appreciated.
License
Released under the MIT License. See LICENSE for details.