H3 Geospatial Indexing Explained: A Practical Guide to Hexagons, Nearby Search, and Location Analytics
Turn latitude and longitude into a useful spatial model. This guide explains H3 cells, resolutions, hierarchy, neighborhood queries, polygon coverage, and production architecture—with practical implementation examples.
A delivery application knows where every courier is. An online marketplace knows where orders originate. A mobility platform receives thousands of changing GPS coordinates. Yet storing latitude and longitude does not automatically make these systems good at answering useful questions.
Which areas have more orders than available couriers? Where is demand growing? How can we summarize millions of location events without sending every point to a browser? How can two teams combine location datasets when their original coordinates never match exactly?
H3 provides a shared geographic unit for these questions: a cell in a global grid.
Developed at Uber, H3 maps locations to identifiers in a hierarchical grid made primarily of hexagons. Applications can group records by those identifiers, compare neighboring areas, and organize analysis at different scales. It is especially useful when a product needs consistent spatial buckets rather than a different polygon for every business question. [1]
This guide uses H3's 4.x API names. The application designs are illustrative engineering patterns; they are not claims about Uber's current production architecture. The examples show how the pieces fit together and should be validated against your installed dependency versions before deployment.
1. From coordinates to geographic buckets
Imagine two orders placed a few meters apart in Amman. Their coordinates differ, so grouping by exact latitude and longitude treats them as unrelated points. Rounding coordinates can create buckets, but the resulting geography depends on the rounding rule and latitude.
With H3, each coordinate is assigned to a cell at a selected resolution. You can then count orders in each cell, join those counts with courier availability, and show the results on a map.
The original coordinate remains valuable. The cell ID is an additional analytical key, not a replacement for the original measurement.
For an order, an application might retain:
| Field | Purpose |
|---|---|
latitude, longitude |
Preserve the observed location |
h3_r9 |
Support local grouping at a fixed resolution |
occurred_at |
Place the event in a time window |
tenant_id |
Keep customer data correctly scoped |
event_id |
Detect replayed or duplicate events |
This combination matters because most operational questions are spatial, temporal, and organizational at the same time. An area can be busy at lunchtime and quiet in the evening. One tenant's demand should not appear in another tenant's dashboard.
2. Why hexagons are useful
On an ideal planar hexagonal grid, a cell has six edge-sharing neighbors with equal center spacing. A square grid introduces a distinction between side neighbors and diagonal neighbors. Hexagons make some neighborhood calculations and movement visualizations easier to interpret. [2]
For a demand dashboard, this creates a convenient local neighborhood: the current area and its surrounding areas. A smoothing operation can combine these neighboring values without first choosing how much a diagonal neighbor should count relative to a side neighbor.
Earth introduces distortion. Real H3 cells are not identical regular hexagons, and their geographic sizes and neighbor distances vary. Treat the symmetry as a useful grid property rather than a promise of exact physical distance everywhere.
There are also pentagons. H3 cannot cover a sphere entirely with hexagons, so every resolution includes twelve pentagonal cells. The system begins with 122 base cells and supports resolutions from 0 through 15. Its construction uses an icosahedron and a spherical Earth model. [3]
Most application code can use the library's normal APIs without implementing this geometry. Custom traversal code, however, should never assume that every cell has exactly six neighbors or that every boundary has exactly six coordinate pairs.
3. Choosing a resolution
A higher resolution gives smaller cells. Smaller cells preserve more local variation but create more groups, more map features, and often less statistically stable counts.
The following values are rounded global averages for hexagons, not guarantees for a particular location. Pentagon areas differ. [4]
| Resolution | Average hexagon area | A possible starting experiment |
|---|---|---|
| 5 | 252.904 km² | Broad regional patterns |
| 6 | 36.129 km² | Large urban sectors |
| 7 | 5.161 km² | Citywide operational summaries |
| 8 | 0.737 km² | Local demand analysis |
| 9 | 0.105 km² | Detailed urban aggregation |
| 10 | 0.015 km² | Dense local observations |
| 11 | 0.00215 km² | Fine-grained site analysis |
These suggested experiments are design choices, not official usage prescriptions. An appropriate resolution depends on the density and accuracy of your data, the decision being made, and the cost of an incorrect interpretation.
A practical selection exercise is to render the same dataset at three adjacent resolutions. Compare empty-cell frequency, events per populated cell, response size, and how often an operational decision changes when you move the grid scale.
Avoid choosing the smallest available cells simply because they appear more precise. A GPS fix with substantial uncertainty does not become more accurate when assigned to a very small cell.
For density calculations, use the actual area of each cell rather than assuming every cell has the global average:
events_per_square_kilometer = event_count / actual_cell_area_km2
H3 exposes per-cell area calculations separately from average-area utilities. [5]
4. Your first H3 program with JavaScript
The official JavaScript binding is h3-js. Install the 4.x major version and commit your package lockfile so that deployments use the reviewed dependency version. The official repository documents both module imports and the API. [6]
npm install h3-js@4
Save this example as h3-demo.mjs:
import {
latLngToCell,
cellToLatLng,
cellToBoundary,
getResolution,
isValidCell,
} from 'h3-js';
const latitude = 31.9539;
const longitude = 35.9106;
const cell = latLngToCell(latitude, longitude, 9);
console.log({
cell,
valid: isValidCell(cell),
resolution: getResolution(cell),
center: cellToLatLng(cell),
boundary: cellToBoundary(cell),
});
Run it with node h3-demo.mjs. The cell represents an area containing the input location. Its returned center is a representative grid center, not the original point; the documentation also distinguishes this center from the geographic centroid of the projected cell. [7]
Keep cell IDs as strings when exchanging JSON with JavaScript. H3 indexes have a 64-bit representation, while ordinary JavaScript numbers cannot exactly represent every 64-bit integer. String transport avoids an unnecessary precision problem. The index also encodes the resolution and hierarchy; it is not just an arbitrary database-generated identifier. [8]
Do not sort IDs and interpret the order as a nearest-neighbor ranking. Spatial proximity requires appropriate grid or geographic operations.
5. Build a demand heatmap
Suppose a backend receives a set of completed orders. The following example groups synthetic observations at resolution 9:
import { latLngToCell } from 'h3-js';
const orders = [
{ id: 1, lat: 31.9539, lng: 35.9106 },
{ id: 2, lat: 31.9540, lng: 35.9107 },
{ id: 3, lat: 31.9800, lng: 35.8400 },
];
const counts = new Map();
for (const order of orders) {
const cell = latLngToCell(order.lat, order.lng, 9);
counts.set(cell, (counts.get(cell) ?? 0) + 1);
}
const result = [...counts].map(([cell, orderCount]) => ({
cell,
orderCount,
}));
console.log(result);
In a real system, deduplicate events before counting them. A retried webhook should not create another order in the same bucket. Decide whether cancellations subtract from demand, whether pending requests count, and whether the event time or processing time determines the time window.
These rules are more important to dashboard correctness than the choice of color palette. A technically correct cell assignment can still produce a misleading business metric.
For a browser map, return aggregated cells and values. Convert only the cells that need to be displayed into polygons. Sending raw customer locations to the frontend adds bandwidth and may expose data the viewer does not need.
6. Export cells as GeoJSON
H3's default JavaScript coordinate ordering is latitude followed by longitude. GeoJSON positions use longitude followed by latitude. Make this conversion explicit. The cellToBoundary option shown below returns GeoJSON-compatible ordering and closes the ring. [6][7]
import { cellToBoundary } from 'h3-js';
export function toFeatureCollection(rows) {
return {
type: 'FeatureCollection',
features: rows.map(({ cell, orderCount }) => ({
type: 'Feature',
id: cell,
properties: { cell, orderCount },
geometry: {
type: 'Polygon',
coordinates: [cellToBoundary(cell, true)],
},
})),
};
}
Apply the function to the result array from the preceding example. For an international map, also check how the rendering library handles polygons near the antimeridian. A polygon that crosses the date line needs more careful display handling than an ordinary local polygon.
Choose color ranges deliberately. A moving maximum can make two snapshots look similar despite very different demand. Fixed thresholds support comparison, while percentile-based ranges can help reveal variation within one snapshot. Tell readers which interpretation your map uses.
7. Nearby cells and nearby people are different questions
gridDisk finds cells within a specified number of grid steps, including the origin. gridDistance measures hops between cells, not meters. Some grid-distance operations can fail across large separations or pentagonal distortion, so failure handling is part of the API contract. [9]
import { latLngToCell, gridDisk } from 'h3-js';
const origin = latLngToCell(31.9539, 35.9106, 9);
const candidateCells = gridDisk(origin, 2);
console.log(candidateCells);
This is useful for obtaining an operational neighborhood. It does not prove that every point within a two-kilometer radius has been covered. The requested number of hops is not a distance unit, and a point may sit near a cell edge.
For an exact radius search, define a candidate region that conservatively contains the entire search circle, retrieve records from that region, and apply an exact geographic distance predicate. Validate candidate coverage independently; an exact final predicate cannot recover records that the first stage excluded.
If you have not established a reliable conservative H3 covering strategy, start with a spatial database query directly. Add H3 filtering only after demonstrating that it preserves the required results and improves the measured workload.
For delivery dispatch, a geographic radius is still only an initial filter. Road connectivity, direction restrictions, traffic, and availability determine whether a courier can actually serve the order. Rank a bounded candidate set with a routing engine when travel time is the business requirement.
8. PostgreSQL: combine spatial truth with analytical cells
An application can store H3 IDs as text while keeping a PostGIS geography column for exact distance queries. This example deliberately avoids depending on an H3 PostgreSQL extension:
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE location_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
event_key TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
h3_r9 TEXT NOT NULL,
position GEOGRAPHY(POINT, 4326) NOT NULL,
UNIQUE (tenant_id, event_key)
);
CREATE INDEX location_events_cell_time_idx
ON location_events (tenant_id, h3_r9, occurred_at);
CREATE INDEX location_events_position_gix
ON location_events USING GIST (position);
The producer computes h3_r9 using an approved H3 binding. When inserting position, supply longitude first to ST_MakePoint and assign SRID 4326. Keep the coordinate and cell assignment consistent in the same write path.
For a distance query:
SELECT id, occurred_at, h3_r9
FROM location_events
WHERE tenant_id = $1
AND occurred_at >= $2
AND ST_DWithin(
position,
ST_SetSRID(ST_MakePoint($3, $4), 4326)::geography,
$5
);
Here $3 is longitude, $4 is latitude, and $5 is a radius in meters. The geography form of ST_DWithin uses meters and defaults to spheroidal distance measurement. [10]
For a dashboard, ordinary SQL grouping uses the materialized cell:
SELECT h3_r9 AS cell, COUNT(*) AS event_count
FROM location_events
WHERE tenant_id = $1
AND occurred_at >= $2
AND occurred_at < $3
GROUP BY h3_r9;
The example cell-first B-tree index is a starting point for cell-filtered queries. Time-window scans across many cells may benefit from a different index order or time partitioning. Use representative data and execution plans; a schema example is not a universal performance guarantee.
9. Integrating H3 into Laravel
A Laravel application needs a trusted place to calculate H3 cells. Common architectural choices include a PHP-compatible binding, a database extension, or an internal indexing service using a binding maintained in your chosen language. Review packaging, maintenance, and deployment compatibility before selecting one.
The following interface is application code you define yourself. It is not a built-in Laravel API or a recommendation for a particular third-party package:
interface SpatialIndexer
{
public function cellFor(
float $latitude,
float $longitude,
int $resolution
): string;
}
Keep controllers independent of the binding so that validation, persistence, and operational monitoring remain stable if the implementation changes.
$validated = $request->validate([
'latitude' => ['required', 'numeric', 'between:-90,90'],
'longitude' => ['required', 'numeric', 'between:-180,180'],
]);
$cell = $indexer->cellFor(
(float) $validated['latitude'],
(float) $validated['longitude'],
9
);
The snippet assumes the application has injected $indexer. Authenticate the request, derive tenant scope from the authenticated principal, and persist the coordinate together with its cell. Never trust a client-provided H3 value as authoritative without validating or recomputing it.
For an analytics endpoint, bound the requested time range and geographic extent. Reject requests that would generate excessive numbers of cells. An authenticated user should not be able to request an entire continent at a tiny cell size and exhaust a worker.
If indexing happens asynchronously, decide what a temporarily unindexed event looks like. A nullable cell field, processing state, and replayable job may be appropriate. Dashboard freshness should reflect that lag rather than implying every event is already included.
10. Hierarchy: the subtle issue that changes rollups
H3 supports parent and child relationships. A normal hexagonal cell has seven immediate logical children below the finest resolution; pentagonal cells have a different subdivision structure. Parent relationships are exact in the index hierarchy, while geographic containment between resolutions is approximate. [11]
import { latLngToCell, cellToParent } from 'h3-js';
const fineCell = latLngToCell(31.9539, 35.9106, 9);
const logicalParent = cellToParent(fineCell, 8);
const directlyIndexedCoarseCell = latLngToCell(31.9539, 35.9106, 8);
console.log({ logicalParent, directlyIndexedCoarseCell });
Do not assume the two coarse identifiers always match. A point near an inter-resolution boundary can be assigned differently by direct coarse indexing and by rolling up its fine cell.
Choose and document your aggregation semantics:
| Strategy | What it preserves |
|---|---|
| Index the original coordinate separately at each resolution | Direct geographic assignment at each requested scale |
| Index at a base resolution and derive ancestors | Consistent logical rollups from the chosen base grid |
Both can be useful. Problems arise when two pipelines silently use different strategies and users expect their totals per cell to agree.
For financial or operational reporting, include the resolution and rollup strategy in dataset metadata. If you change either, version the transformation and plan a controlled backfill rather than mixing incompatible results in one aggregate table.
11. Polygon coverage and delivery zones
polygonToCells converts a polygon into a cell collection using a center-based containment rule. That is different from selecting every cell that intersects the polygon and different from requiring every selected cell to lie completely inside it. [12]
import { polygonToCells } from 'h3-js';
// Synthetic rectangle for demonstration; not an official city boundary.
// Coordinates use GeoJSON ordering: longitude, latitude.
const polygon = [[
[35.85, 31.90],
[36.00, 31.90],
[36.00, 32.02],
[35.85, 32.02],
[35.85, 31.90],
]];
const cells = polygonToCells(polygon, 8, true);
console.log(cells.length);
For a promotional heatmap, this approximation may be suitable. For delivery eligibility at an exact service boundary, use the original polygon and a deliberate boundary policy.
A point inside the service polygon can belong to a cell whose center lies outside it. Therefore, a center-filled cell set is not automatically a safe candidate filter for exact eligibility. A final point-in-polygon check handles false positives but cannot restore an excluded candidate.
Version service-zone polygons. If operations changes a zone, invalidate derived cell sets and caches together. Include the zone version in stored decisions when support teams need to explain why an address was accepted yesterday and rejected today.
12. Joining spatial datasets without multiplying rows
H3 can serve as a common spatial key across datasets that originally used different point locations. [13]
Consider orders and available couriers. Aggregate each dataset to one row per tenant, cell, and time bucket before joining. If you directly join every order to every courier in the same cell, the resulting row count grows multiplicatively and corrupts simple counts.
Use a full outer join or a known cell universe when the dashboard must include areas with zero supply or zero demand. An inner join can hide the most operationally interesting cells: busy areas with no couriers.
Define a shortage metric carefully. A ratio needs a zero-supply rule, a freshness cutoff for courier pings, and a clear interpretation of demand. Orders in the last fifteen minutes and couriers online right now are different types of measurements. H3 aligns the geographic dimension; it does not resolve the time semantics for you.
13. Movement and origin-destination analysis
For completed journeys, group by an origin cell, destination cell, and time bucket. This produces an origin-destination matrix that can reveal recurring flows between areas.
H3 also provides directed-edge indexes for adjacent cells. They represent direction in the grid and can carry movement-related weights. [14]
Keep those concepts separate. An origin-destination pair may span an entire city; a directed edge connects neighbors. A sparse GPS trace may skip cells entirely. Do not claim it proves the exact sequence of roads or grid edges traversed unless a separate reconstruction method supports that conclusion.
Similarly, a grid path is a path across cells. A navigation route needs a road graph and routing constraints.
14. Compaction and large region representations
compactCells replaces complete logical child sets with parent cells where possible. uncompactCells expands the result to a selected resolution. The compaction input should use a single resolution, and the result may contain mixed resolutions. [15]
This can reduce storage for large cell sets, but it changes how membership queries must work. A fine-resolution cell will not necessarily match a compacted parent through simple string equality.
Decide whether consumers should expand compacted data before use or understand ancestor membership directly. Store the target resolution and representation type alongside the data. Without that metadata, an apparently valid array of H3 IDs can be interpreted incorrectly.
Budget expansion work. A compact representation that looks small in storage can still expand into a very large number of fine cells.
15. Production architecture and performance
A useful initial architecture is straightforward: validate incoming events, calculate a base cell, store raw facts, aggregate by cell and time, and expose bounded map queries.
Start with database aggregation and measure it. When real workloads justify precomputation, maintain aggregate tables keyed by tenant, cell, resolution, metric, and time bucket. Preserve enough raw data to rebuild them after a logic change.
For an event-driven pipeline, make updates idempotent. Store event identities or use a reliable deduplication strategy before applying increments. Test what happens when a worker crashes after updating a counter but before acknowledging the event.
Caching requires the same discipline. Cache keys should include tenant, resolution, time interval, filters, and any zone or transformation version. Align TTLs with the freshness promise shown to users. A five-minute-old heatmap can be appropriate for planning and unacceptable for live dispatch.
Benchmark the complete request path. Measure candidate counts, database query time, aggregation latency, GeoJSON serialization, response bytes, and browser rendering cost. An efficient index does not prevent an oversized payload from freezing the map.
Avoid unsupported promises such as “H3 makes every query constant time.” Lookup speed still depends on row counts, index selectivity, storage layout, concurrency, and the work performed after candidate retrieval.
16. Privacy, authorization, and data quality
A cell ID still describes a location. Fine spatial buckets combined with timestamps can reveal sensitive patterns even when names are removed.
For public analytics, consider coarser resolution, minimum-count suppression, restricted time windows, retention limits, and controls against repeated differencing queries. These are design considerations; H3 alone does not provide an anonymity guarantee.
Enforce tenant access before aggregation and caching. A dashboard that accidentally combines two customers' data remains a data isolation failure even if it only displays hexagons.
Validate location quality separately from coordinate ranges. A latitude can be numerically valid but implausible for the claimed service region. Flag stale pings, impossible jumps, suspiciously repeated coordinates, and device timestamps that differ substantially from server time.
17. H3, Geohash, S2, and PostGIS
These technologies can overlap, but they solve different layers of a spatial system.
| Tool | Useful properties | Decision to examine |
|---|---|---|
| H3 | Primarily hexagonal cells and logical hierarchy | Does your workload benefit from area aggregation and neighborhood analysis? |
| Geohash | String-based hierarchical geographic encoding | Do existing systems and prefix-based workflows already use it? |
| S2 | Hierarchical cells that are quadrilateral on Earth, square in their projection | Is exact geometric subdivision especially important? |
| PostGIS | Spatial types, predicates, and database indexes | Do you need distance queries, polygon operations, and spatial joins? |
The H3 documentation explains the neighbor and subdivision differences with S2 and the encoding and latitude-related area differences with Geohash. [16][17]
A sensible system can use H3 alongside PostGIS: cells organize metrics and candidate regions, while spatial predicates enforce precise geographic requirements. Choose by workload and correctness requirements, then benchmark the proposed design with real distributions.
18. Mistakes to catch before launch
Review these cases during implementation:
- Coordinate reversal: verify latitude/longitude input against GeoJSON longitude/latitude output.
- Mixed resolutions: reject or explicitly normalize incompatible cell sets.
- Boundary omissions: compare candidate retrieval with an independent spatial baseline.
- Unsafe numeric conversion: preserve IDs through API and database round trips.
- Duplicate events: replay a batch and verify counts remain correct.
- Incorrect rollups: test points near inter-resolution boundaries.
- Pentagon assumptions: include exceptional cells in traversal tests.
- Stale live state: expire courier availability after a defined heartbeat gap.
- Unbounded geometry: cap time windows, geographic extent, and generated features.
- Tenant leakage: test aggregate endpoints and caches with two separate tenants.
The most valuable correctness test for nearby search is recall: does the candidate stage include every record the independent exact-distance query finds? Measure false positives too, but do not optimize them by silently losing valid results.
19. A practical starting plan
Begin with one product question, such as “Where did orders concentrate during the last hour?” Keep the original coordinates, choose a candidate resolution, and build a small aggregation endpoint and map.
Then add an adjacent resolution and compare what changes. Decide whether the product needs direct per-resolution indexing or logical ancestor rollups. Record that decision in the data contract.
For nearby search, establish an exact database baseline before introducing H3 candidate filtering. For service areas, keep original polygons as authoritative. For live operations, add freshness and replay handling before sophisticated predictions.
H3 becomes most useful when it gives multiple teams a consistent geographic vocabulary. A well-defined cell, time window, and metric can connect dashboards, dispatch tools, forecasting pipelines, and operational reports without forcing every component to invent its own spatial boundaries.
Official references
- H3 project introduction
- Hexagonal aggregation
- Overview of the H3 indexing system
- Cell statistics across resolutions
- Area and other miscellaneous functions
- Official h3-js repository and API
- Coordinate indexing and boundaries
- H3 cell index structure
- Grid traversal functions
- PostGIS ST_DWithin
- Logical and geographic hierarchy
- Polygon and region functions
- Joining datasets
- Flow modelling
- Hierarchical grid functions
- H3 and S2 comparison
- H3 and Geohash comparison
Featured Articles

Kafka in Production: Partition Strategy, Consumer Lag, Reliability, and an Incident Playbook
A production Kafka cluster needs more than brokers. Learn to choose partition keys, plan retention and capacity, monitor lag, handle rebalances, and rehearse failure recovery.

Laravel and Kafka Without Lost Events: The Transactional Outbox, Idempotent Consumers, and PostgreSQL
A database commit and a Kafka publish cannot safely be treated as one ordinary transaction. This guide builds an outbox and consumer design that survives crashes and duplicate delivery.

Apache Kafka Explained: Topics, Partitions, Consumer Groups, and Your First Event Pipeline
Follow one order event from producer to consumers, then run a local Kafka topic and learn what partitions, offsets, keys, and consumer groups actually do.
Comments
0 commentsNo approved comments are visible yet. New community replies may wait for moderation.