Geospatial (H3)¶
Hexagonal hierarchical geospatial indexing, built in. HyperDjango compiles the
mattsta/h3 fork of the H3 hexagonal-indexing
library directly into its native extension, so you get fast, dependency-free
spatial bucketing and proximity search — no PostGIS, no pip install h3.
Overview¶
H3 divides the globe into a grid of hexagonal cells at 16 nested resolutions (0 = coarsest, 15 = finest). Each cell is a 64-bit index. The two things that make it useful for application code:
- Bucketing — every latitude/longitude maps to exactly one cell at a chosen
resolution. Rows sharing a cell are "near" each other, so a plain
WHERE cell = ?becomes a spatial lookup with an ordinary B-tree index. - Proximity — from any cell you can enumerate the surrounding cells (a "disk"),
turning "find everything near here" into an
IN (...)over indexed integers.
Cells are plain Python ints and fit a Postgres BIGINT, so no special column
type is required.
from hyperdjango import geo
# Index a point (degrees) to a resolution-9 cell (~170 m edge)
cell = geo.lat_lng_to_cell(37.7749, -122.4194, 9) # 617700169957507071
# The cell plus its ring of neighbours — a proximity fan-out
nearby = geo.grid_disk(cell, 1) # 7 cells (center + 6)
Import geo explicitly — it is not pulled in at package init. It requires the
native extension (build with uv run hyper-build); calling any function on an
older extension without H3 raises a RuntimeError with a rebuild hint.
Resolutions¶
Pick the resolution whose cell size matches your bucket. Common choices:
| Resolution | Average edge length | Typical use |
|---|---|---|
| 4 | ~22 km | Region / metro-area bucketing |
| 6 | ~3.2 km | City-district recall |
| 7 | ~1.2 km | Neighbourhood |
| 8 | ~460 m | "Nearby" search in a dense city |
| 9 | ~170 m | Block-level proximity |
| 11 | ~25 m | Fine-grained venue clustering |
| 15 | ~0.5 m | Maximum precision |
A finer resolution means more, smaller cells (more precise, larger disks needed to cover the same real-world radius). A coarser resolution means fewer cells (cheaper to index and shard, less precise). See the mattsta/h3 fork for the full resolution table.
API¶
All functions live in hyperdjango.geo.
lat_lng_to_cell(lat_deg, lng_deg, resolution) -> int¶
Index a latitude/longitude given in degrees to the H3 cell at resolution
(0..15). This is the write path: compute a cell when a row is created and store
it in a BIGINT column.
Raises ValueError on a resolution outside 0..15 or a non-finite coordinate.
It never returns a fabricated cell.
grid_disk(origin_cell, k) -> list[int]¶
Every cell within grid distance k of origin_cell — a filled hexagonal disk.
k=0 is just [origin_cell]; k=1 adds the six immediate neighbours; each
larger k adds another ring.
Order is not significant. Raises ValueError on a negative k or a malformed
cell. Near one of H3's twelve pentagons a disk can be slightly smaller than the
ideal 1 + 3·k·(k+1); the result only ever contains real cells.
grid_disk_distances(origin_cell, k) -> list[tuple[int, int]]¶
Like grid_disk, but each cell is paired with its ring distance
(0..k) from the origin. Use it for adaptive ring expansion — widen the
search outward, cheapest ring first, until you have enough candidates.
for cell_id, distance in geo.grid_disk_distances(cell, 2):
... # distance 0 = origin, 1 = first ring, 2 = second ring
grid_distance(cell_a, cell_b) -> int | None¶
The number of grid steps between two cells, or None when no honest integer
distance exists — the cells are at different resolutions, or the shortest path
would cross a pentagon. Diagnostics only; it returns None rather than guessing.
geo.grid_distance(cell, cell) # 0
geo.grid_distance(cell_res9, cell_res7) # None (resolution mismatch)
cell_to_parent(cell, parent_resolution) -> int¶
The coarser ancestor cell at parent_resolution (which must be 0..15 and no
finer than the cell's own resolution). Every cell beneath one parent shares its
parent index, which makes the parent a natural coarse shard or bucket key.
fine = geo.lat_lng_to_cell(37.7749, -122.4194, 9)
shard = geo.cell_to_parent(fine, 5) # 599685771850416127
Raises ValueError if parent_resolution is finer than the cell's resolution.
get_resolution(cell) -> int¶
The resolution (0..15) of a cell. Handy to validate a value read back from
storage before trusting it.
Raises ValueError if the value is not a well-formed H3 cell.
Storing cells on a model¶
A cell is an ordinary integer. Store it in a BIGINT column and index it — no
spatial extension needed. Use Field(big=True) so the column is BIGINT rather
than the default 32-bit INTEGER (H3 indexes are 64-bit).
from hyperdjango import Model, Field
from hyperdjango import geo
class Venue(Model):
name: str
lat: float
lng: float
# H3 res-9 cell — 64-bit, so big=True → BIGINT, and index it for lookups
h3_cell: int = Field(big=True, index=True)
class Meta:
table = "venues"
async def create_venue(name: str, lat: float, lng: float) -> Venue:
return await Venue.objects.acreate(
name=name,
lat=lat,
lng=lng,
h3_cell=geo.lat_lng_to_cell(lat, lng, 9),
)
See Models & ORM for Field(big=True) and the other column options.
Example: "venues near me"¶
Combine lat_lng_to_cell (bucket the query point) with grid_disk (expand to a
neighbourhood) and a single indexed IN query:
from hyperdjango import geo
async def venues_near(lat: float, lng: float, k: int = 1) -> list[Venue]:
"""Venues in the caller's cell and the surrounding k rings."""
origin = geo.lat_lng_to_cell(lat, lng, 9)
cells = geo.grid_disk(origin, k) # center + neighbours
return await Venue.objects.filter(h3_cell__in=cells).all()
Because h3_cell is an indexed BIGINT, this is a plain index range scan — no
distance math in the database. Widen k to search a larger radius, or drop to a
coarser resolution (e.g. 8) for bigger cells and smaller disks.
For sparse data, expand adaptively instead of guessing k up front:
async def venues_until(lat: float, lng: float, want: int = 20) -> list[Venue]:
origin = geo.lat_lng_to_cell(lat, lng, 9)
found: list[Venue] = []
for k in range(0, 6): # widen ring by ring
cells = geo.grid_disk(origin, k)
found = await Venue.objects.filter(h3_cell__in=cells).all()
if len(found) >= want:
break
return found
Notes¶
- Degrees in, integers out. Coordinates are degrees; cells are
BIGINT-safeints. A valid H3 index reserves its high bit, so it always fits a positive signed 64-bit integer. - Honest failures. Every function raises
ValueErroron bad input rather than returning a wrong cell.grid_distancereturnsNonewhen there is no defined answer. - SIMD-accelerated. H3 is compiled with the per-ISA SIMD kernels the build target supports (NEON on ARM64, AVX2/AVX-512 on x86-64), selected at compile time from the target CPU's feature set. Results are bit-identical across kernels — the acceleration is transparent.