System Design
Advanced 13 min read Data at scale

Geospatial indexing: geohash, quadtrees and H3

In one line

To answer 'what is near me' quickly, you map 2D locations onto cells (geohash strings, quadtree nodes, S2 or H3 cells) that ordinary indexes and key-value stores can look up, then search the user's cell plus its neighbours and filter by exact distance.

What it is

A normal B-tree index sorts one dimension. A query like 'drivers within 2 km' needs two dimensions (latitude and longitude), and an index on latitude alone returns a long strip across the whole country. Geospatial indexes solve this by dividing the earth into cells and giving each cell an ID, so nearby points share or neighbour the same cell IDs.

Geohash encodes a point as a base-32 string by repeatedly halving longitude and latitude ranges. Each extra character makes the cell smaller: 5 characters is about 4.9 km x 4.9 km, 6 is about 1.2 km x 0.6 km, 7 is about 153 m x 153 m. Points with a common prefix are usually close, so a prefix search in any sorted store (Redis sorted set, B-tree, DynamoDB sort key) finds candidates. Edge case: two points 10 m apart across a cell boundary can have different prefixes, so you always search the 8 neighbouring cells too.

A quadtree recursively splits a square into 4 children until each leaf holds at most K points (say 100). Dense areas like central Bengaluru get deep, small cells; empty areas stay large. It adapts to density, but it usually lives in memory and must be rebuilt or rebalanced as points move.

Google's S2 maps the sphere onto a cube and a Hilbert curve, giving 64-bit cell IDs at 31 levels. Uber's H3 uses hexagons at 16 resolutions; every hexagon has 6 neighbours at the same distance, which makes ring searches, smoothing and surge-pricing zones cleaner than with squares. Both have libraries to cover a circle or polygon with a set of cells.

Why interviewers ask

'Find nearby drivers', 'restaurants near me', 'Yelp' and 'Tinder' style questions are interview favourites, and the location index is the heart of the answer. Interviewers look for: why a plain index fails, how you pick cell size, the neighbour problem, how you handle hundreds of thousands of location updates per second, and the difference between static points (restaurants) and moving points (drivers).

How it works

  1. 1Classify the data: static points (shops, restaurants, ATMs) change rarely and suit a database index; moving points (drivers, riders, delivery partners) update every few seconds and suit an in-memory index.
  2. 2Choose the cell size from the search radius: make cells about the size of a typical search, so a search covers the centre cell plus its neighbours (9 geohash cells or an H3 ring of 7) without scanning far too many points.
  3. 3Write path for moving points: on each GPS ping, compute the new cell; if it changed, remove the driver from the old cell and add to the new one; always update the driver's latest position and timestamp. Expire drivers whose last ping is older than about 30 s.
  4. 4Read path: compute the user's cell, get the neighbour cells, fetch all candidates in those cells, compute exact distance (haversine) or, better, road ETA, filter by radius and status (available, vehicle type), sort and return the top N.
  5. 5If too few results, expand: search the next ring of cells or a coarser resolution, up to a maximum radius.
  6. 6Shard by geography: partition the index by city or by coarse cell (for example H3 resolution 3 or 4) so each shard holds one region; a city like Mumbai may need several shards. Watch for hot shards during rain or events.
  7. 7For static points, use built-in support: PostGIS with a GiST index and ST_DWithin, MongoDB 2dsphere, Elasticsearch geo_point with geo_distance queries, or Redis GEOSEARCH, which stores 52-bit geohash scores in a sorted set.
  8. 8Use road distance for ranking: straight-line distance ignores rivers, flyovers and one-way roads. Index by straight-line distance to get candidates cheaply, then rank the top 20 to 50 by ETA from a routing engine (OSRM, Valhalla or a maps API).

Worked example · Nearby drivers for a ride-hailing app in Bengaluru

  1. Supply: 100,000 online drivers in the city, each sending GPS every 4 s = 25,000 location writes per second.
  2. Index: H3 resolution 8 (hexagons with an area of about 0.74 square km, edge about 0.46 km). In Redis, key drivers:{h3cell} is a set of driver IDs; key driver:{id} stores lat, lng, status and timestamp.
  3. Most pings stay in the same cell: only about 10% change cells, so about 2,500 per second are remove-and-add operations; the rest just update driver:{id}.
  4. Rider at Koramangala requests a cab: compute the rider's res-8 cell and its first ring (k = 1), 7 cells in total covering roughly a 1 to 1.5 km radius.
  5. Fetch drivers in the 7 cells: about 60 candidates. Drop stale ones (last ping older than 30 s) and those not available or of the wrong vehicle type: 25 left.
  6. Call the routing engine for ETA to the top 25 by straight-line distance; rank by ETA and offer the ride to the best driver. Total p99 about 150 ms.
  7. Late at night only 2 candidates are found, so expand to ring k = 2 (19 cells) before giving up.

Picture it

Geospatial indexing is like PIN codes. To find tailors near you, you do not check every tailor in India; you check your own PIN code and the neighbouring ones, then measure the actual distance to the few you found. Smaller PIN areas mean fewer tailors to check, but more neighbouring areas to look at.

Reference code

# Nearby drivers with H3 + Redis (h3-py v4 API)
import h3, time

RES = 8

def update_location(r, driver_id, lat, lng):
    cell = h3.latlng_to_cell(lat, lng, RES)
    old = r.hget(f"driver:{driver_id}", "cell")
    pipe = r.pipeline()
    if old and old.decode() != cell:
        pipe.srem(f"cell:{old.decode()}", driver_id)
    pipe.sadd(f"cell:{cell}", driver_id)
    pipe.hset(f"driver:{driver_id}", mapping={"lat": lat, "lng": lng, "cell": cell, "ts": time.time()})
    pipe.execute()

def candidates(r, lat, lng, k=1):
    cells = h3.grid_disk(h3.latlng_to_cell(lat, lng, RES), k)  # centre + k rings
    ids = set()
    for c in cells:
        ids |= {i.decode() for i in r.smembers(f"cell:{c}")}
    return ids  # then drop stale, filter by status, rank by ETA

Key patterns

Cell + neighbours + exact filter

When: Any radius search on cell-based indexes

How: Look up the centre cell and its neighbours to get candidates, then filter by exact distance and business rules.

Separate live location from history

When: Moving objects such as drivers and couriers

How: Latest position in memory (Redis or an in-process grid), full history streamed to an append-friendly store.

Adaptive resolution

When: Density varies a lot between areas

How: Quadtrees split dense areas automatically; with fixed grids, expand rings or move to a coarser resolution when results are too few.

Precomputed polygon coverage

When: Serviceability or zone lookups

How: Cover each polygon with cells once (H3 polyfill or S2 RegionCoverer) and store cell -> zone, turning point-in-polygon checks into key lookups.

Real-world use

Uber H3

Uber built and open-sourced H3 to bucket supply, demand and pricing data into hexagons. Hexagons give equal-distance neighbours, which makes surge zones and demand forecasts smoother than square grids.

Google S2

Developed at Google for geographic data and used by systems such as MongoDB's 2dsphere index and Google BigQuery's geography functions. S2 covers arbitrary regions with a small set of cells at mixed levels, which is efficient for polygons like delivery zones.

Redis GEO commands

GEOADD and GEOSEARCH store points in a sorted set using a 52-bit geohash as the score, so radius queries become range lookups. A common choice for moving-object indexes at moderate scale.

PostGIS

The PostgreSQL extension for geometry and geography types, with R-tree-like GiST indexes. Used for static points and polygons: serviceability zones, store locators and pincode boundaries.

Quick-commerce serviceability (Swiggy, Zepto, Blinkit)

Dark stores serve fixed polygons around them. Checking 'is this address serviceable and from which store' is a point-in-polygon query, often pre-computed into cells (geohash or H3) so it becomes a fast key lookup.

In a project

A bike-taxi startup stores driver locations in MySQL and finds nearby drivers with WHERE lat BETWEEN ... AND lng BETWEEN .... At 40,000 online drivers it works, but on a rainy evening in Hyderabad the query takes 2 seconds and the database falls over from constant location updates.

  1. Move live driver locations out of MySQL into a Redis cluster: one set per H3 resolution-8 cell plus a hash per driver, with a 30 s staleness check.
  2. Ingest pings through a location service that batches Redis updates with pipelining and only moves a driver between sets when the cell changes.
  3. Shard Redis by city, and split the busiest city by coarse H3 cell, so Hyderabad's rain spike does not slow down other cities.
  4. Search the rider's cell plus ring 1, expand to ring 2 if fewer than 5 available drivers are found, then rank the top 20 by ETA from an OSRM routing cluster.
  5. Write location history asynchronously to Kafka and then to a time-series or wide-column store for trip replay, fraud checks and demand heatmaps; MySQL keeps only trips and payments.
  6. Precompute H3 demand and supply counts per cell every 30 s to drive surge pricing and driver heatmaps.

Impact: Nearby-driver p99 falls from 2 s to about 80 ms, the database no longer takes 20,000 location writes per second, and rain spikes in one city stay contained to that city's shard.

Trade-offs

Use it when

  • Geohash: simple prefix search on any sorted store (Redis, DynamoDB, Cassandra, Postgres) with roughly uniform density
  • Quadtree: in-memory index with very uneven density, such as a city centre versus outskirts
  • H3: ride-hailing, delivery, surge pricing and analytics where equal-distance neighbours and ring searches matter
  • S2: covering arbitrary polygons and regions efficiently; global systems
  • PostGIS, Elasticsearch geo: static points and polygons with rich query needs

Avoid it when

  • A plain lat/lng B-tree index for radius queries at scale
  • Storing high-frequency moving locations in a relational primary
  • Straight-line distance as the final ranking in cities with rivers, flyovers and one-way roads
  • One global index shard; partition by geography and plan for hot cities

Common mistakes

  • Searching only the user's own cell and missing a driver 50 m away across the boundary.
  • Choosing a cell size far smaller than the search radius (too many cells to query) or far larger (too many candidates to filter).
  • Writing every GPS ping to the main database instead of an in-memory index plus asynchronous history.
  • Not expiring stale locations, so drivers whose app crashed keep receiving ride offers.
  • Forgetting that geohash cells are not square at all levels and distort near the poles; fine for India, but know it.
  • Ignoring hot spots: a stadium after a match or an airport at night concentrates demand into a few cells and one shard.

Interview questions

Quick revision

  • Problem: 2D proximity does not fit a 1D index; map space to cells.
  • Geohash: base-32 prefix; 5 chars about 4.9 km, 6 about 1.2 x 0.6 km, 7 about 153 m.
  • Always search the centre cell + neighbours, then filter by exact distance.
  • Quadtree: splits dense areas, adapts to density, usually in memory.
  • S2: Hilbert curve on a cube, 64-bit IDs, good polygon covering.
  • H3: hexagons, 16 resolutions, equidistant neighbours, grid_disk for rings.
  • Moving points: in-memory index + TTL; history async to a log/time-series store.
  • Static points: PostGIS GiST, Elasticsearch geo_point, Redis GEOSEARCH.
  • Rank final candidates by road ETA, not straight-line distance.
  • Shard by geography; plan for hot cells and hot cities.

Check yourself

1. Why must a geohash radius search also check neighbouring cells?

2. What is the main advantage of H3 hexagons over square grids?

3. Drivers send GPS updates every 4 seconds. Where should their latest location live?

4. Which structure adapts its cell size automatically to point density?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free