Geo indexes: finding what is near
Find the drivers within 2 km, or the stores within 5 km. A normal index orders one value, and a place has two. This sheet shows how cells and trees turn a radius into a small set of candidates.
- 1A one-column index cannot answer a 2-D query. Map 2-D to cells, or use a tree of boxes.
- 2A geohash prefix is a cell. Search the cell and its 8 neighbours, or you miss points across the border.
- 3Redis GEO is a sorted set scored by a 52-bit geohash. Dense cells make each search slower.
- 4Moving points: keep only the latest position, batch the writes, and expire silent drivers.
- Every method finds candidates first, then checks the exact distance.
- Fewer candidates means less work per query. Dense areas make the gap larger.
- The distance is the haversine formula on a sphere, about 100 ns.
A B-tree orders one column, so a radius query reads a whole latitude band. I need a 2-D index: cells, or a tree of boxes.
| method | where | points read | status |
|---|---|---|---|
| Scan every row | Service | 100,000 | Not approved |
| B-tree on (lat, lng) | Postgres | 14,373 | Small tables |
| One geohash cell | Redis | 3,286 | Misses points |
| Geohash cell + 8 neighbours | GEOSEARCH | 18,575 | Approved |
| Quadtree in memory | Service | 4,710 | Approved |
| R-tree (GiST) on a box | Postgres | 4,508 | Approved |
| PostGIS ST_DWithin on geography | PostGIS | cited | Polygons, metres |
| S2 or H3 cell ids in any store | Library | cited | Global scale |
The query returns 3,565 points. Points read: entries the index visits before the distance check. The B-tree count is the latitude band, which Postgres 16 reads in full.
I store positions in Redis GEO for moving drivers. For fixed places with rich queries I use Postgres, with PostGIS when I can.
Step 1: Driver reports
- The app sends its position.
- It skips a report when the car has not moved and 4 s have not passed.
If it fails
A lost report: the next one replaces it. Only the latest position matters.
Drivers write their latest position to a Redis GEO key per city. A rider's request reads candidates with GEOSEARCH, ranks them by driving time, and Postgres decides the assignment.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Redis | GEOADD | Stores lng, lat as a 52-bit geohash score in a sorted set. A new position replaces the old one. O(log N). | Store and venue lookups |
| Redis | GEOSEARCH BYRADIUS or BYBOX, ASC, COUNT, ANY | Reads the 1 + 8 cells around the centre, drops points outside the shape, sorts by distance. | Nearest stores, geofence checks |
| Redis | GEODIST, GEOPOS, ZREM | Distance on a sphere, positions, and removal. There is no GEODEL; a GEO key is a sorted set. | |
| Redis | Sorted set scored by time | The heartbeat. ZRANGEBYSCORE finds silent drivers. | Delay queues, sliding windows |
| Redis | Pipelines, many members per GEOADD | About 10 times the update rate of single calls, measured. | Bulk loads |
| Redis | One key lives on one shard | Limit A city key cannot spread. Split by city or by coarse cell. | |
| Postgres | GiST index on a point (an R-tree) | Box containment with no extension. The index only visits boxes that meet the query box. | Ranges, exclusion constraints |
| Postgres | B-tree on (lat, lng) | Small tables Reads the whole latitude band and filters longitude. | Any sorted lookup |
| PostGIS | geography, ST_DWithin, GiST | Distances in metres on the spheroid. ST_DWithin compares bounding boxes through the index first. Not installed in the lab. | Polygons, routes, geofences |
| Service | Quadtree in process memory | Leaves split by density. A 1 km suburb query in about 31 µs. | Game worlds, map tiles |
| S2 | Cells on 6 cube faces, levels 0 to 30, Hilbert order, 64-bit ids | A region becomes a few ranges of cell ids, in any key-value store. | Geofencing at global scale |
| H3 | Hexagons, 16 resolutions, 12 pentagons each | Equal-distance neighbours. Good units to count demand per area. | Surge zones, heat maps |
Redis gives me a geohash-scored sorted set with radius search. Postgres gives me R-tree boxes today and PostGIS for real geography. A library gives me S2 or H3 cells.
encode(lat, lng, bits):
ranges: lat [-90, 90], lng [-180, 180]
REPEAT bits times, lng first, then in turn1:
IF value >= middle of its range:
emit 1; keep the upper half
ELSE: emit 0; keep the lower half
RETURN the bits // a prefix is a larger cell2
neighbours(cell):
FOR EACH of the 8 directions:
encode(centre + one cell height or width)3
wrap longitude at ±180; none past a pole4
search(lat, lng, r):
bits = smallest cell with height >= r AND width >= r5
FOR EACH of the cell and its 8 neighbours:
read the score range of the cell
keep points with distance <= r6- 1Interleaving keeps points that are near in both directions near in the code. Redis stores 52 bits.
- 2Fewer bits, larger cell. A cell is one contiguous range of codes, so a sorted set finds it with one range read.
- 3Compute neighbours from the box. Across a halving line the codes share nothing.
- 4Redis refuses latitudes beyond ±85.05° for the same reason.
- 5Then the circle stays inside the 9 cells wherever the centre is in its cell.
- 6Cells are rectangles. The exact check drops the corners.
Tested source Go: encode, decode, neighbours · Go: cell size for a radius · Go: one cell, 9 cells
// Encode interleaves the bits of a binary search on longitude (even bits) and latitude (odd bits).
// Each bit halves the range: 1 means the upper half. More bits make a smaller cell.
func Encode(lat, lng float64, bits int) uint64 {
latLo, latHi, lngLo, lngHi := -90.0, 90.0, -180.0, 180.0
var code uint64
for i := range bits {
code <<= 1
if i%2 == 0 {
mid := (lngLo + lngHi) / 2
if lng >= mid {
code |= 1
lngLo = mid
} else {
lngHi = mid
}
} else {
mid := (latLo + latHi) / 2
if lat >= mid {
code |= 1
latLo = mid
} else {
latHi = mid
}
}
}
return code
}
// Decode returns the cell of a code of the given length: the box every point with that prefix
// lies in.
func Decode(code uint64, bits int) Box {
b := Box{-90, -180, 90, 180}
for i := range bits {
bit := code>>(bits-1-i)&1 == 1
if i%2 == 0 {
mid := (b.MinLng + b.MaxLng) / 2
if bit {
b.MinLng = mid
} else {
b.MaxLng = mid
}
} else {
mid := (b.MinLat + b.MaxLat) / 2
if bit {
b.MinLat = mid
} else {
b.MaxLat = mid
}
}
}
return b
}
// Neighbours returns the 8 cells around a cell, clockwise from north. A cell's neighbour can have
// a very different code: across a halving line the prefixes share nothing. So compute it from
// the box, never from the string. Past a pole there is no neighbour (ok false); past the 180°
// meridian, longitude wraps.
func Neighbours(code uint64, bits int) (cells [8]uint64, ok [8]bool) {
b := Decode(code, bits)
h, w := b.MaxLat-b.MinLat, b.MaxLng-b.MinLng
lat, lng := (b.MinLat+b.MaxLat)/2, (b.MinLng+b.MaxLng)/2
steps := [8][2]float64{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}
for i, s := range steps {
nLat, nLng := lat+s[0]*h, lng+s[1]*w
if nLat > 90 || nLat < -90 {
continue
}
nLng = math.Mod(nLng+540, 360) - 180
cells[i], ok[i] = Encode(nLat, nLng, bits), true
}
return cells, ok
}
// BitsFor picks the cell for a radius: the smallest cell (most bits, an even number) whose
// height and width are both at least r. Then the circle fits inside the cell and its 8
// neighbours, wherever the centre falls in its cell.
func BitsFor(r, lat float64) int {
for bits := Bits; bits >= 2; bits -= 2 {
if h, w := CellSize(bits, lat); h >= r && w >= r {
return bits
}
}
return 2
}
// SearchCell scans only the cell that holds the centre. It misses points across the cell border.
func (ix *Index) SearchCell(lat, lng, r float64) Search {
bits := BitsFor(r, lat)
c := Encode(lat, lng, bits)
return ix.scan(lat, lng, r, bits, []uint64{c})
}
// SearchNine scans the centre cell and its 8 neighbours. The circle fits inside these 9 cells, so
// it finds every point; the distance check drops the corners.
func (ix *Index) SearchNine(lat, lng, r float64) Search {
bits := BitsFor(r, lat)
c := Encode(lat, lng, bits)
cells := []uint64{c}
ns, ok := Neighbours(c, bits)
for i, n := range ns {
if ok[i] {
cells = append(cells, n)
}
}
return ix.scan(lat, lng, r, bits, cells)
}
func (ix *Index) scan(lat, lng, r float64, bits int, cells []uint64) Search {
s := Search{Bits: bits, Cells: cells}
for _, c := range cells {
for _, p := range ix.Cell(c, bits) {
s.Scanned++
if Distance(lat, lng, p.Lat, p.Lng) <= r {
s.Found = append(s.Found, p)
}
}
}
return s
}
A geohash interleaves longitude and latitude bits, so a prefix is a cell. I search the cell and its 8 neighbours, sized so the circle fits.
| case | geohash | result |
|---|---|---|
| Query 30 m from the east edge | tdr1v9 | One cell finds the 400 m point. 9 cells find the 80 m point. |
| Two points 3.1 m apart, across the equator | s00000, 7zzzzz | 0 characters shared |
- A shared prefix means near. Near does not mean a shared prefix.
- On 300 seeded queries over 20,000 points, the single cell missed points and the 9 cells never did. A test checks both.
A point across the cell edge can be the nearest one. One cell misses it; 9 cells find it.
| method | points read | found |
|---|---|---|
| One geohash cell | 51 | 35 of 49 |
| Cell + 8 neighbours | 337 | 49 of 49 |
| Quadtree | 61 | 49 of 49 |
| Scan everything | 1,500 | 49 of 49 |
- cell
- 28 bits: 1,222 m × 2,381 m
- why
- The smallest cell at least as tall and as wide as the radius.
- missed
- 14 points within 1 km, in neighbour cells
- geohash cell, as in Redis
- quadtree leaf, in the service
- within the radius, missed
Click or drag on the map to move the query. Arrow keys work when the map has focus.
1,500 seeded points in a 16 km square: an even spread, a dense core and a second hub to the south-east. 49 positions × 4 radii, each a lab run checked against a full scan.
Cells cost the most where points are dense, because the cell size follows the radius and not the density. A quadtree follows the density.
| query | found | lat band | 9 cells | quadtree | box |
|---|---|---|---|---|---|
| city core, 1 km | 3,565 | 14,373 | 18,575 | 4,710 | 4,508 |
| city core, 5 km | 31,638 | 53,466 | 83,786 | 34,093 | 33,981 |
| suburb, 1 km | 100 | 3,026 | 946 | 193 | 140 |
| suburb, 5 km | 2,864 | 16,910 | 69,659 | 4,096 | 3,668 |
| query | cell size | one cell finds | Redis GEOSEARCH | differs |
|---|---|---|---|---|
| city core, 1 km | 1.2 × 2.4 km | 2,509 | 3,563 | 2 |
| city core, 5 km | 9.8 × 19.0 km | 31,152 | 31,633 | 5 |
| suburb, 1 km | 1.2 × 2.4 km | 59 | 100 | 0 |
| suburb, 5 km | 9.8 × 19.0 km | 2,531 | 2,860 | 4 |
Differs: points on which Redis and the Go haversine disagree. A test shows each lies within 2 m of the circle, from 52-bit positions and a 0.03% larger Earth radius. The 10 nearest drivers matched in both places: 58 m in the core, 350 m in the suburb.
In the core, 9 geohash cells read 5 times the result; a quadtree or an R-tree box reads about 1.3 times. Redis agrees with the Go code except on the circle edge.
insert(p):
leaf = walk down to the quarter that holds p
add p to leaf
IF leaf holds more than 16 points1:
split it into 4 quarters
within(lat, lng, r):
visit nodes whose box meets the circle's box2
in each leaf: keep points with distance <= r
nearest(lat, lng, k):
queue = [root], closest first3
WHILE fewer than k found:
e = pop the closest
IF e is a point: report it // nothing left is closer4
ELSE: push its children, keyed by distance to their box- 1The leaf capacity. Every leaf holds at most 16 points, so the work per leaf is fixed wherever it is.
- 2Prune whole subtrees. A dense core is many small leaves; an empty edge is one big leaf.
- 3A node is keyed by the distance to the nearest point of its box: a lower bound for anything inside.
- 4Best-first search. The lab read 26 points to find the 10 nearest in the core.
Tested source Go: insert and within · Go: nearest k
// Insert walks down to the leaf whose box holds p. A full leaf splits into four quarters and
// hands its points down.
func (q *Quadtree) Insert(p Point) {
n := q.root
for n.kids != nil {
n = &n.kids[n.quarter(p)]
}
n.pts = append(n.pts, p)
if len(n.pts) > LeafCap && n.depth < maxDepth {
n.split()
}
}
func (n *node) split() {
b := n.box
midLat, midLng := (b.MinLat+b.MaxLat)/2, (b.MinLng+b.MaxLng)/2
n.kids = &[4]node{
{box: Box{midLat, b.MinLng, b.MaxLat, midLng}, depth: n.depth + 1}, // north-west
{box: Box{midLat, midLng, b.MaxLat, b.MaxLng}, depth: n.depth + 1}, // north-east
{box: Box{b.MinLat, b.MinLng, midLat, midLng}, depth: n.depth + 1}, // south-west
{box: Box{b.MinLat, midLng, midLat, b.MaxLng}, depth: n.depth + 1}, // south-east
}
pts := n.pts
n.pts = nil
for _, p := range pts {
k := &n.kids[n.quarter(p)]
k.pts = append(k.pts, p)
}
for i := range n.kids {
if len(n.kids[i].pts) > LeafCap && n.kids[i].depth < maxDepth {
n.kids[i].split()
}
}
}
func (n *node) quarter(p Point) int {
q := 0
if p.Lat < (n.box.MinLat+n.box.MaxLat)/2 {
q = 2
}
if p.Lng >= (n.box.MinLng+n.box.MaxLng)/2 {
q++
}
return q
}
// Within visits only the nodes whose box meets the circle's bounding box, and checks the
// distance of every point in the leaves it reaches.
func (q *Quadtree) Within(lat, lng, r float64) (found []Point, leaves []Box, scanned int) {
bb := BoundingBox(lat, lng, r)
var walk func(n *node)
walk = func(n *node) {
if !n.box.Intersects(bb) {
return
}
if n.kids == nil {
leaves = append(leaves, n.box)
for _, p := range n.pts {
scanned++
if Distance(lat, lng, p.Lat, p.Lng) <= r {
found = append(found, p)
}
}
return
}
for i := range n.kids {
walk(&n.kids[i])
}
}
walk(q.root)
return found, leaves, scanned
}
// Nearest returns the k points closest to (lat, lng), nearest first. One queue holds nodes, keyed
// by the distance to their box, and points, keyed by their own distance. A point that comes out
// of the queue is closer than everything still in it, so it is the next answer.
func (q *Quadtree) Nearest(lat, lng float64, k int) (found []Point, scanned int) {
pq := &queue{{d: minDist(q.root.box, lat, lng), n: q.root}}
for pq.Len() > 0 && len(found) < k {
e := heap.Pop(pq).(entry)
switch {
case e.p != nil:
found = append(found, *e.p)
case e.n.kids == nil:
for i := range e.n.pts {
p := &e.n.pts[i]
scanned++
heap.Push(pq, entry{d: Distance(lat, lng, p.Lat, p.Lng), p: p})
}
default:
for i := range e.n.kids {
c := &e.n.kids[i]
heap.Push(pq, entry{d: minDist(c.box, lat, lng), n: c})
}
}
}
return found, scanned
}
- leaves
- 13,510 leaves for 100,000 points, recorded
- depth
- 2,561 at depth 6, 5,448 at depth 7, 1,894 at depth 8, 3,459 at depth 9, 148 at depth 10
A quadtree splits only where points are dense. For nearest k I pop nodes and points from one queue ordered by distance, so the first k points out are the answer.
DROP TABLE IF EXISTS places;
CREATE TABLE places (
id int PRIMARY KEY,
lat double precision NOT NULL CHECK (lat BETWEEN -90 AND 90),
lng double precision NOT NULL CHECK (lng BETWEEN -180 AND 180),
pt point GENERATED ALWAYS AS (point(lng, lat)) STORED1
);
CREATE OR REPLACE FUNCTION distance_m2(lat1 float8, lng1 float8, lat2 float8, lng2 float8)
RETURNS float8 LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT 2 * 6371008.8 * asin(sqrt(
sin(radians(lat2 - lat1) / 2) ^ 2
+ cos(radians(lat1)) * cos(radians(lat2)) * sin(radians(lng2 - lng1) / 2) ^ 2))
$$;
CREATE INDEX places_lat_lng ON places (lat, lng);3
CREATE INDEX places_pt ON places USING gist (pt)4;
ANALYZE places;
-- The same box as a containment test, which only the GiST index can answer.
SELECT id FROM places
WHERE pt <@ box(point($2, $1), point($4, $3))5
AND distance_m($5, $6, lat, lng) <= $7;- 1A generated point, so the GiST index can hold it. x is the longitude.
- 2The haversine formula in SQL. A plain function, the same in every query.
- 3Range on lat only. Postgres 16 tests lng on every entry in the latitude band.
- 4An R-tree of boxes. It visits only boxes that meet the query box.
- 5Box containment: only the GiST index can answer it. Then the exact distance.
distance(a, b): // haversine, on a sphere1
h = sin²(Δlat/2) + cos(lat_a)·cos(lat_b)·sin²(Δlng/2)
RETURN 2 · R · asin(√h) // R = 6,371 km
box(lat, lng, r):
dlat = r / R // radians
dlng = dlat / cos(lat)2 // meridians meet at the poles- 1Redis uses the same formula. Its documentation gives a worst-case error of 0.5% against the real Earth.
- 2A degree of longitude is 111 km at the equator and about 108 km at 13° N.
Tested source Go: distance and box
// Distance is the great-circle distance in metres between two points (the haversine formula):
// a = sin²(Δlat/2) + cos(lat1)·cos(lat2)·sin²(Δlng/2), and d = 2R·asin(√a).
func Distance(lat1, lng1, lat2, lng2 float64) float64 {
dLat, dLng := rad(lat2-lat1), rad(lng2-lng1)
a := math.Pow(math.Sin(dLat/2), 2) + math.Cos(rad(lat1))*math.Cos(rad(lat2))*math.Pow(math.Sin(dLng/2), 2)
return 2 * EarthRadius * math.Asin(math.Sqrt(a))
}
// BoundingBox is the smallest lat/lng box around a circle of r metres. A degree of latitude is
// always about 111 km; a degree of longitude shrinks with cos(lat). Valid away from the poles and
// the 180° meridian.
func BoundingBox(lat, lng, r float64) Box {
dLat := r / EarthRadius * 180 / math.Pi
dLng := dLat / math.Cos(rad(lat))
return Box{lat - dLat, lng - dLng, lat + dLat, lng + dLng}
}
PostGIS is not installed in the lab Postgres, so it is cited only. A test checks both queries against a full scan.
Without PostGIS I store lat and lng, index a point with GiST, query its bounding box, and check the haversine distance. With PostGIS I use geography and ST_DWithin.
| system | cell | levels | neighbours | best at |
|---|---|---|---|---|
| Geohash | Rectangle, a bit prefix | Redis: 26 steps, 52 bits | 8; across a halving line they share no prefix | Range scans over sorted keys |
| Quadtree | Square, split by count | Grows with density | Any number; leaf sizes differ | In-memory search over uneven data |
| R-tree (GiST) | Boxes that may overlap | Balanced tree | None; boxes may overlap | Shapes and points in a database |
| S2 | Quadrilateral on a cube face | 0 to 30; level 13 averages 1.27 km² | 4 along the edges | Covering a region with a few id ranges |
| H3 | Hexagon, 12 pentagons per level | 0 to 15; level 9 averages 0.11 km² | 6, all at the same distance | Counting per area: demand, surge zones |
- S2 orders cells along a Hilbert curve, so near cells often have near ids.
- An S2 level 30 cell averages 0.74 cm². H3 has 122 cells at resolution 0.
- Neither library is in the lab. The figures come from their documentation.
Geohash for sorted keys, a quadtree or R-tree for density, S2 for regions at global scale, H3 when I count things per area.
| technique | what it does | effect | where |
|---|---|---|---|
| Overwrite the position | GEOADD on an existing member moves it. | Memory stays one entry per driver. History goes to a log, not the index. | Redis |
| One GEOADD per report | Simple, one round trip each. | About 41,000 updates a second, measured. | Redis |
| 100 drivers per GEOADD | Each service copy flushes a batch every 100 ms or so. | About 418,000 updates a second, measured. | Redis |
| Skip small moves | Report after 20 m or 4 s, whichever comes first. | A parked car sends one report every 4 s instead of every second. | Driver app |
| A key per city or coarse cell | Spreads keys over cluster shards. | A query near a boundary reads two keys. | Cluster |
| Heartbeat and sweeper | ZADD seen now; ZRANGEBYSCORE for silent drivers; ZREM. | Closed apps leave search within the sweep period. | Redis |
| A row per driver in Postgres, updated every 4 s | Each update writes a new row version and index entries. | Vacuum load grows with the update rate (sheet T3). Keep only assignments there. | Postgres |
I keep only the latest position, batch the writes, skip small moves, and shard by city. A heartbeat removes drivers who stop reporting.
| event | result | why it stays safe | saved by |
|---|---|---|---|
| Hot cell: a dense city core | One 1 km search reads 18,575 drivers: about 2 ms, about 600 searches a second per Redis, measured. | ANY stops at COUNT: about 0.18 ms. Or shrink the radius where density is high. | COUNT ANY |
| Update storm: every driver reports at once | Single GEOADD calls reached about 41,000 a second in the lab. | Batches, skipped small moves and a key per city spread the load. | Batching |
| A driver closes the app | The position stays in the GEO key forever. | The sweeper removes silent drivers. Assign checks the status anyway. | Heartbeat |
| Search uses one cell only | Points across the border are missed. | Always read the 8 neighbours too; GEOSEARCH does. | 9 cells |
| Query near 180° longitude or the poles | A naive box wraps the wrong way. | Split the box at 180°. Redis refuses latitudes beyond ±85.05°. | Split box |
| Two riders get the same driver | Both requests saw the driver as a candidate. | The conditional UPDATE lets one commit. The other gets 0 rows and tries the next driver. | Conditional update |
| Straight-line distance across a river | The nearest driver by distance is far by road. | Distance only filters. The routing service ranks by driving time. | Routing |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | Postgres, lat and lng with a GiST index on a point; PostGIS when available. | Fixed places: stores, venues. A 1 km box query in about 0.2 ms in a suburb, 2.6 ms in the dense core. | Positions change every few seconds, and updates fill the table with dead rows. |
| 2 | Redis GEO, one key, latest position only, with a heartbeat. | About 41,000 single updates and 11,500 suburb searches a second. | Updates near the single-call limit, or searches in the core slow down. |
| 3 | A key per city on separate shards; batched GEOADD; COUNT with ANY in dense areas. | About 418,000 batched updates a second per shard. Cities grow by adding shards. | One city's core is still too dense for one shard. |
| 4 | An in-memory index in the matching service, fed by the position stream. | About 31 µs per suburb query, so about 32,000 a second per core. Split by region. | You need polygons, areas and analytics at global scale. |
| 5 | S2 or H3 cell ids as keys in any store, plus PostGIS for shapes. | Regions become id ranges; demand counts per hexagon. | Top of the ladder. |
Demand example: one city with 100,000 active drivers reporting every 4 s is 25,000 updates a second; 500 ride requests a second at peak. Capacity measured in the lab on 100,000 seeded drivers, 32 clients; the quadtree figure is 1 / 31 µs on one core. Read the bars as orders of magnitude.
For fixed places I start with Postgres and a GiST index. For moving drivers I start with one Redis GEO key per city. I shard, or move search into memory, when a city's core gets hot.
0 of 9 known
Why is a B-tree on (lat, lng) a poor index for "within 1 km"?
Two points 3.1 m apart have the geohashes s00000 and 7zzzzz. Why do they share no character?
Why does a geohash search read 9 cells and not 1?
How does Redis pick the cell size for GEOSEARCH?
GEOSEARCH with 1 km and COUNT 20 takes about 2 ms in the city core and 0.25 ms in a suburb. Why, and what helps?
One million drivers report every 4 s. Can one Redis keep up?
A driver closes the app. What removes them from search?
Why does a quadtree read fewer points than a geohash grid in a dense core?
When would you choose H3 hexagons over geohash cells?
- degrees
- 1° of latitude is 111 km. 1° of longitude is 111 km × cos(latitude), derived.
- geohash
- Redis keeps 52 bits. A 28-bit cell at 13° N is 1,222 × 2,381 m, derived.
- H3, S2
- H3 level 9 averages 0.11 km²; S2 level 13 averages 1.27 km² (their documentation).
- distance
- Haversine about 107 ns; up to 0.5% off the real Earth (Redis documentation).
- GEOADD
- About 41,000 a second one by one; 418,000 in batches of 100, measured.
- GEOSEARCH
- 1 km: about 0.25 ms in a suburb, 2.1 ms in the core, 0.18 ms in the core with ANY, measured.
- candidates
- Core, 1 km: 18,575 for 9 cells, 4,710 for a quadtree, 3,565 found, recorded.
- suburb
- 1 km: 100 found; 9 cells read 946, recorded.
Go, Redis 8 and Postgres 16 on an 8-core laptop, loopback, 32 clients, median of three runs. Use these as orders of magnitude.