lumo

Module 18 · Lesson 3 of 10Free

Spatial grids: geohash, S2 and H3 compared

Level
Advanced
Topic
Location, maps and matching systems
Reading time
19 min

When you finish this page you can

  1. Explain how geohash, S2 and H3 organize locations into cells.
  2. Separate cell membership, hierarchical ownership and geographic containment.
  3. Choose a grid and resolution from query costs and correctness requirements.
  4. Implement candidate retrieval without treating cell membership as a distance test.

Builds on 2 lessons

Start with lesson 118 min
  1. Proximity search: finding nearby places18 min
  2. Geohash: encoding locations as short strings19 min

Video lesson · coming soon

At , a courier stands 20 metres from a restaurant. Dispatch searches the restaurant’s grid cell and finds nobody. The courier is across the cell boundary.

Would replacing rectangular cells with hexagons fix this? How should Rydo choose between , S2 and H3 without losing nearby couriers?

For this lesson’s scenario, Rydo searches within a 2 km radius. It also groups courier supply into neighbourhood reports. Those jobs share coordinates, but they do not necessarily need the same grid settings.

A cell is not a radius

Check yourselfAnswer in your head first, then reveal
Two couriers are equally close to a restaurant. One shares its cell; the other is in the next cell. Does changing to smaller cells make a same-cell search correct?Show answerHide answer
No. Smaller cells move the boundaries and introduce more of them. A nearby courier can still be across an edge, so the query must cover the search region rather than select only the restaurant’s cell.

A divides a surface into named cells. Turning coordinates into a cell identifier lets a database group nearby records.

The proximity search lesson established the two-stage approach: retrieve possible matches, then test their distances. A grid supplies the first stage.

Diagram not drawn yet: spatial-grids-cover-then-filter.svg

A restaurant requests a 2 km search. A query planner selects every grid cell intersecting the search circle. A cell index returns couriers from those cells, including some outside the circle. A distance filter removes outside couriers and returns nearby candidates.

Cells reduce the records examined. Coordinates decide whether a courier is within the requested radius.

A is a set of cells containing the entire search . It may include extra territory, but it must not leave gaps.

For a complete index, this gives a useful contract:

  • Every courier within the radius appears among the candidates.
  • Some candidates may lie outside the radius.
  • The distance test removes those extra candidates.

Selecting cells whose centres lie inside the circle does not satisfy that contract. A cell centre can be outside even when part of its cell overlaps the circle.

This boundary problem exists with all three grids. Their differences appear in how they name cells, represent neighbours and relate coarse cells to fine ones.

Three grids organize space differently

Geohash keeps prefixes useful

Geohash encodes a latitude–longitude rectangle as a string. Longer strings identify smaller cells. Shorter strings identify larger cells. 1

As covered in Geohash, extending a prefix selects a smaller rectangle inside it. That makes prefix grouping useful when your storage already supports string keys.

Rydo could group fine-cell records by their shared prefix. But adjacent rectangles can sit across a prefix boundary. A prefix scan is therefore a region lookup, not a nearest-neighbour operation.

Cell dimensions also change with latitude. Elastic’s dimensions table explicitly describes equatorial sizes rather than universal dimensions. 1 A precision selected for one city should not become an unmeasured global distance assumption.

Geohash is attractive when the existing index already uses it. Moving away needs a measured benefit, not a preference for another shape.

S2 nests cells on a sphere

S2 starts with six cube faces projected onto a sphere. Each cell divides into four children. Its cells are spherical quadrilaterals, rather than latitude–longitude rectangles. 2

The nesting gives Rydo a useful operation: represent a large region with coarse cells and refine its boundary with smaller cells. Descendants stay within their parent’s geographic boundary.

S2 stores cell identity in 64 bits and orders cells along a Hilbert curve. This curve visits spatial regions in an ordered sequence. 2 The identifier is an address within that ordering, not a distance measurement.

An implementation can exploit hierarchy when searching or grouping regions. However, readers and writers must agree on levels and descendant lookup rules. A coarse covering cannot directly equality-match an index containing only fine-cell IDs.

S2’s advantage here is hierarchical region organization. It does not remove the need to cover boundaries correctly.

H3 favours hexagonal neighbourhoods

H3 builds a mostly hexagonal grid using an icosahedron, a solid with twenty faces. Each also contains twelve pentagons. 3

For an ideal flat hexagonal patch, each cell has six edge-sharing neighbours. That gives neighbourhood analysis a consistent local pattern, unlike square grids where edge and diagonal neighbours differ.

This motivates testing H3 for Rydo’s supply reports. A neighbourhood could combine a cell with its surrounding cells. However, that is a grid neighbourhood, not a fixed road distance or travel time.

H3 has a subtle hierarchy rule. A hexagonal cell has seven logical children, but those children do not fit exactly inside its geographic outline. Logical ownership is exact; geometric containment across resolutions is approximate. 4

At one resolution, cell boundaries remain well-defined. The limitation concerns relationships between resolutions, not uncertainty about which fine cell contains a point.

That difference matters when Rydo turns fine-cell counts into coarse reports.

A parent can mean two things

Suppose Rydo indexes each courier in a fine H3 cell. It then replaces each fine-cell ID with its logical parent to produce a neighbourhood report.

The resulting count answers:

“How many indexed couriers belong to this parent in the hierarchy?”

It need not answer:

“How many courier coordinates lie inside this coarse cell’s geographic outline?”

For H3, directly indexing a coordinate at a coarse resolution can differ from taking the parent of its fine-resolution cell. 4

Diagram not drawn yet: spatial-grids-parent-containment.svg

Two separate panels compare hierarchy. In the S2 panel, four child quadrilaterals fit inside a parent boundary. In the H3 panel, seven logical child hexagons are associated with one parent, but some child territory crosses the parent’s geographic boundary. A point in the crossing territory belongs logically to that parent without lying inside its outline.

S2 subdivisions nest geographically. H3 parentage is exact as an index relationship, but its geographic outlines do not nest exactly.

Neither H3 report is inherently wrong. The error is promising one interpretation and computing the other.

For a stable hierarchy-based dashboard, choose a fine resolution and roll up its logical parents. For counts inside a particular geographic boundary, test coordinates against that boundary instead.

This also separates dispatch from reporting. Dispatch needs complete candidate retrieval for a radius. Reports need a documented rule for assigning events to groups.

The grid choice should follow those contracts, rather than forcing every operation into one interpretation of “nearby”.

Choose the operation before the shape

Which grid should Rydo use for candidate search and neighbourhood reports?

Keep geohash when the current index already meets measured search and reporting needs.

Rydo keeps its existing prefix-based storage. Region grouping stays straightforward, but dispatch still needs a covering across prefix boundaries.

Choose S2 when nested geographic regions drive the design.

Geographically nested cells support hierarchical region lookup. The implementation must still handle mixed levels and boundary cells correctly.

Why this choice

For this scenario, Rydo pilots H3 because neighbourhood comparisons are the new product need. Dispatch can reuse its buckets only after the covering implementation passes boundary tests.

The simplest alternative is to keep the existing geohash index. That wins if it already meets targets and reporting only needs rectangular buckets. A migration adds maintenance and work.

S2 becomes preferable if the central requirement changes to nested geographic regions. H3 remains useful when its neighbourhood operations simplify the application enough to justify another dependency.

The choice is provisional. Compare candidate counts, cell lookups and update costs on the same coordinates. Do not compare equal resolution numbers: each system defines its own scale.

Question Geohash S2 H3
Natural representation String prefix Hierarchical cell ID Hierarchical cell ID
Main appeal here Existing prefix index Nested regions Local aggregation
Boundary responsibility Cover adjacent cells Cover the region Cover the region
interpretation Nested rectangles Nested spherical cells Logical parents
What still decides radius membership? Coordinates Coordinates Coordinates

The pilot selects a grid family. It has not yet selected a resolution, which often determines more of the query cost.

Smaller cells exchange one cost for another

Large cells reduce the number of bucket lookups. They also bring more unrelated couriers into the candidate set.

Small cells reject more territory before records are read. However, the query must enumerate and retrieve more buckets.

Use an illustrative local workload, not a published benchmark. Assume the search region has roughly uniform density. All areas below are measured totals for hypothetical conservative coverings, not actual H3 resolution sizes.

Estimate couriers inside the circle

π × (2 km)² ≈ 12.6 km²; 12.6 km² × 100 couriers/km² ≈ 1,260 couriers

About this many records must survive any complete radius search under the scenario’s density assumption.

Measure a coarse covering

18 km² × 100 couriers/km² = 1,800 candidates

This illustrative covering needs 20 bucket lookups. About 540 candidates are outside the circle.

Measure a fine covering

14 km² × 100 couriers/km² = 1,400 candidates

This illustrative covering needs 80 bucket lookups. About 140 candidates are outside the circle.

Compare the exchanged work

1,800 − 1,400 = 400 fewer candidate checks; 80 − 20 = 60 extra lookups

Finer cells help only if the saved record work outweighs the extra covering and retrieval work.

Should Rydo use the finest available resolution?

Use coarse cells.

Few lookups are needed, but crowded cells return many candidates. This can work when bucket retrieval is expensive and distance checks are cheap.

Use fine cells.

The boundary approximation becomes tighter, but lookup count and index maintenance may grow. This can work when candidate retrieval dominates.

Why this choice

Start with one fixed resolution. It keeps writes and equality lookups easy to reason about.

In the worked example, finer cells save only a few hundred candidate checks. That alone does not justify extra lookups. those lookups may change the result.

Reconsider when dense districts dominate candidate reads or larger query radii produce too many buckets. An adaptive hierarchy may help, but adds level-aware reads and more complicated updates.

The useful target is lower total work per successful query, not smaller cells by themselves.

Keep the index contract explicit

Rydo’s small implementation needs three responsibilities. The grid planner is code inside dispatch, not a separate service.

Component Responsibility Talks to State kept On failure
Location writer Validate and save latest position Cell index Accepted version Courier position ages
Query processor Cover, retrieve, filter Cell index Request-local candidates Search returns an error
Cell index Store current courier records Writer and processor Coordinates and cell IDs Dispatch cannot discover candidates

The cell index can begin as a database table:

sql
CREATE TABLE courier_latest (
  courier_id TEXT PRIMARY KEY,
  scheme TEXT NOT NULL,
  resolution SMALLINT NOT NULL,
  cell_id TEXT NOT NULL,
  latitude DOUBLE PRECISION NOT NULL
    CHECK (latitude BETWEEN -90 AND 90),
  longitude DOUBLE PRECISION NOT NULL
    CHECK (longitude BETWEEN -180 AND 180),
  position_version BIGINT NOT NULL,
  received_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX courier_cell_lookup
  ON courier_latest (scheme, resolution, cell_id);

The gives each courier one current row. The secondary index serves “couriers in these cells”. The indexes lesson explains the database mechanism.

The write path is:

  1. Authenticate the reporting courier and validate the coordinates.
  2. Encode the point using the configured scheme and resolution.
  3. Update coordinates, cell ID and timestamp together.
  4. Reject an older position version rather than moving the courier backwards.

Assume versions increase within an authenticated reporting . A new session needs a server-recognized generation; a phone counter alone is not enough.

The read path is:

  1. Construct a conservative covering at the stored resolution.
  2. Fetch rows matching its cell IDs.
  3. Reject stale positions using the configured freshness rule.
  4. Test distance using each remaining row’s coordinates.
  5. Return candidates to dispatch, which separately checks .

A failed bucket read is not an empty bucket. Returning partial results as complete could leave a waiting rider unmatched.

Implement the final filtering step

The query processor owns this function. It accepts already-retrieved buckets and a distance predicate.

The predicate keeps geographic distance calculation outside this example. Production supplies a tested geographic implementation; the grid does not supply that guarantee.

python
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass

@dataclass(frozen=True)
class Courier:
    id: str
    latitude: float
    longitude: float
    received_at: float

def filter_candidates(
    buckets: Mapping[str, Sequence[Courier]],
    covered_cells: Sequence[str],
    cutoff: float,
    within_radius: Callable[[float, float], bool],
) -> list[str]:
    matches: set[str] = set()
    for cell in set(covered_cells):
        for courier in buckets.get(cell, ()):
            if courier.received_at < cutoff:
                continue
            if within_radius(courier.latitude, courier.longitude):
                matches.add(courier.id)
    return sorted(matches)
ts
type Courier = Readonly<{
  id: string;
  latitude: number;
  longitude: number;
  receivedAt: number;
}>;

function filterCandidates(
  buckets: ReadonlyMap<string, readonly Courier[]>,
  coveredCells: readonly string[],
  cutoff: number,
  withinRadius: (latitude: number, longitude: number) => boolean,
): string[] {
  const matches = new Set<string>();
  for (const cell of new Set(coveredCells)) {
    for (const courier of buckets.get(cell) ?? []) {
      if (courier.receivedAt < cutoff) continue;
      if (withinRadius(courier.latitude, courier.longitude)) {
        matches.add(courier.id);
      }
    }
  }
  return [...matches].sort();
}

Here, an absent map entry means a successfully read, empty bucket. The retrieval layer must raise dependency failures before calling this function.

The set prevents duplicate output IDs. It cannot repair missing coverage, stale records or conflicting location versions.

That boundary is important: a correct filter cannot recover a courier the index never returned.

Production failures test the contract

Use Rydo’s established four-second location reporting interval. For an illustrative fleet of 8,000 online couriers, that creates 2,000 position writes per second.

That is the incoming workload, not a database capacity claim. Measure index-update cost as well as query latency.

A courier crosses a boundary. With the table design, one row update changes coordinates and cell membership together. If cells become separate distributed buckets, moving between them needs an explicit consistency policy. Otherwise, dispatch may briefly miss the courier or see two versions.

A phone stops reporting. Cell membership alone keeps the courier discoverable forever. A freshness cutoff removes old positions. Monitor the share of rejected positions to distinguish poor connectivity from an empty neighbourhood.

One district becomes crowded. Smaller cells may reduce , but cannot eliminate couriers genuinely inside the radius. Monitor candidates per query and rows per cell. A large result is not always an indexing failure.

The index times out. Record the dependency error and return a retryable search failure. Do not tell the rider that no couriers exist. Recovery uses bounded retries or an explicitly designed fallback.

A new resolution is deployed. Readers must not switch before the new index is populated. Build it alongside the old one, compare results, then switch. Keep the old reader available during rollback.

Test points on opposite sides of cell edges, near vertices and around global seams. Compare indexed answers with a full coordinate scan over the same frozen dataset. This checks coverage independently from traffic timing.

Common mistakes

  • Using one neighbour ring for every radius. Grid steps are not a universal distance budget.
  • Treating cell IDs as distances. Identifier order is not a radius predicate.
  • Comparing raw counts as density. Density divides count by measured cell area.
  • Confusing discovery with reservation. Finding a courier does not prevent another request from assigning them.

Where this shows up

The grid contract is now explicit; the next lesson asks how Redis can serve these location queries with low latency.

Check your understanding

Check yourselfAnswer in your head first, then reveal
A covering includes only cells whose centres are inside the search circle. A coordinate scan finds nearby couriers that the index missed. Where should you look first?Show answerHide answer
Check the covering rule, not the final distance filter. A boundary cell can intersect the circle even when its centre lies outside, so centre-only selection can omit valid candidates.
Check yourselfAnswer in your head first, then reveal
Rydo rolls fine H3 counts into logical parents. A report counts couriers outside a displayed parent outline. Does this prove the hierarchy is broken?Show answerHide answer
No. Logical parentage does not promise exact geographic containment across H3 resolutions. Keep the logical report clearly labelled, or count coordinates against the displayed geographic boundary.
Check yourselfAnswer in your head first, then reveal
A finer covering saves 400 candidate checks but requires 60 more bucket lookups. Should Rydo deploy it?Show answerHide answer
Not from those counts alone. Measure the saved record-processing cost against covering, retrieval and update costs, including the effect of batching.
Check yourselfAnswer in your head first, then reveal
One bucket lookup times out and the others return no couriers. Can dispatch safely report that nobody is nearby?Show answerHide answer
No. The failed lookup leaves part of the search region unknown. Report a search failure or use a fallback whose coverage and freshness are explicit.

Summary

  1. Choose a grid from its operations and boundary guarantees; use a conservative covering and coordinate tests for radius search.
  2. Geohash supports prefix regions; S2 provides geographically nested cells; H3 supports hexagonal neighbourhood analysis.
  3. H3 logical rollups are exact index relationships, not exact containment within coarse geographic outlines.
  4. Finer cells trade fewer candidate records for more lookups. Measure the complete request and update paths.
  5. Keep coordinates, versions and freshness alongside cell IDs. Missing coverage and failed reads cannot be repaired by filtering.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.