Geospatial cells with S2 and H3
Open in the interactive app: practice, quiz, mock interview →In one minute
- S2 (from Google) and H3 (from Uber) are hierarchical grids that cover the whole sphere and give every cell a 64-bit integer ID. Both fix geohash's worst problems: severe distortion near the poles and awkward neighbour logic.
- S2 projects the Earth onto the six faces of a cube, divides each face as a quadtree, and orders cells along a Hilbert curve. A cell's descendants form one contiguous ID range, so "inside this cell" is a single range scan.
- H3 projects onto a 20-faced icosahedron and tiles it with hexagons. Every neighbour is the same distance away, which makes it the natural choice for aggregation, smoothing, and movement analysis.
- Rule of thumb: S2 for indexing and exact containment, H3 for bucketing and analytics.
How it works
S2: cube, quadtree, Hilbert curve
Each point on the sphere is projected onto one of six faces of a surrounding cube, and a non-linear transform evens out cell sizes so that, at any level, areas differ by only about a factor of two: level-10 cells range from 48.8 to 102.0 square km (S2 cell statistics). Geohash cells, by contrast, shrink to slivers near the poles.
Each face is a quadtree 30 levels deep; a level-30 leaf is under a square centimetre. Cells are numbered along a Hilbert curve, which, unlike geohash's Z-order curve, never jumps: consecutive cells are always edge neighbours. The six faces' curves are oriented so that each one ends where the next begins, giving one continuous loop over the whole sphere (S2 cell hierarchy).
The 64-bit cell ID packs the face (3 bits), 2 bits per level, and a trailing 1 bit marking the level. All descendants of a cell therefore occupy one contiguous range of leaf IDs, so containment is range_min <= id <= range_max, which any B-tree or sorted key store handles.
Cell area falls 4x per level: level 10 averages about 81 square km, level 16 about 140 m across.
S2 coverings
The region coverer takes a polygon or circle plus limits (min level, max level, max cells) and returns mixed-size cells covering it: big cells inside, small cells along the boundary. The default is 8 cells (s2region_coverer.h), which gives a loose cover; 200 give a tight one at the cost of more lookups. Never go below 4: with at least 4 cells the covering's area is at worst about 10 times the region's, but with fewer it can be astronomically larger (S2 coverings guide). The covering is the query plan: one range scan per cell, then an exact geometry test.
H3: icosahedron and hexagons
H3 projects the sphere onto an icosahedron and lays a hexagonal grid over it. There are 122 base cells at resolution 0. Each finer resolution has about seven times as many cells (aperture 7), over 16 resolutions from 0 to 15. Resolution 7 hexagons average about 5.2 square km, resolution 8 about 0.74 square km, resolution 9 about 0.1 square km, and resolution 15 under one square metre (H3 resolution table).
Hexagons have two consequences. All six neighbours share an edge at the same centre distance, so the ring of cells at distance k (gridDisk) is uniform, whereas square grids mix edge and corner neighbours and bias smoothing. But hexagons cannot be split exactly into smaller hexagons, so a parent's seven children only approximate its area, and truncating IDs is not an exact containment test as it is in S2. H3 approximates the seven-way split by rotating the grid between resolutions; containment is exact in the index, but only approximate on the ground (H3 indexing).
Every resolution also has exactly 12 pentagons, at the icosahedron's vertices, mostly in oceans; some functions return errors when they hit one.
Down the stack
Layer 1The contract
S2 promises an exact hierarchy: every leaf belongs to exactly one cell at each level, and a cell contains a point if and only if the point's leaf ID falls in the cell's ID range. A covering is guaranteed to contain the whole region; an interior covering is guaranteed to lie inside it. H3 promises a deterministic cell for every point at each resolution and exact neighbour relations, but parent-child containment is only approximate, and some grid functions can return errors near pentagons.
Layer 2The mechanism
An S2CellId is a 64-bit integer: 3 face bits, then 2 bits per level of Hilbert position, then a single 1 bit, then zeros. The lowest set bit encodes the level. If lsb = id & -id, the descendants span [id - lsb + 1, id + lsb - 1], and the parent at a coarser level is found by clearing low bits and setting the new lowest bit. Converting a point to a cell uses lookup tables that advance the Hilbert curve 4 levels at a time.
An H3Index is also 64 bits: a reserved bit, a 4-bit mode (1 means cell), 3 reserved bits, a 4-bit resolution, a 7-bit base cell (0 to 121), then fifteen 3-bit digits, each 0 to 6, with unused digits set to 7 (H3 cell index). Function names changed in H3 v4: kRing became gridDisk, geoToH3 became latLngToCell, h3ToParent became cellToParent and polyfill became polygonToCells, so tutorials using the old names target v3 (H3 migration guide). cellToParent lowers the resolution field and sets the trailing digits to 7. Internally, points are projected gnomonically onto an icosahedron face and placed on IJK hexagon coordinates, with the grid rotated between odd and even resolutions.
Layer 3The machine
Point-to-cell conversion is dominated by trigonometry: a few sin, cos, and atan2 calls at roughly 20 to 50 ns each, so one conversion costs on the order of 100 ns to 1 us. After that, everything is integer work. Parent computation is a few bit operations; comparing two IDs is one instruction.
Storage benefits most. An 8-byte key packs about 400 entries into an 8 KB B-tree leaf page. In LSM stores and Bigtable-style systems that keep rows sorted by key, an S2 covering cell is one contiguous key range, so each cell costs one seek and then sequential block reads. The flip side is that a dense city maps to a narrow key range, which concentrates load on one tablet or shard.
Why it's built this way
- A sphere cannot be flattened without distortion, so both systems project onto polyhedron faces where distortion is bounded.
- Cube faces split cleanly into quadrants, so S2 can use exact quadtree bits and a Hilbert curve for contiguous ranges.
- Hexagons give equidistant neighbours, but a sphere cannot be tiled by hexagons alone, so H3 must include exactly 12 pentagons.
- Databases and caches handle integers best, so both encode cells as 64-bit IDs with the hierarchy in the bits.
Diagram
flowchart TD
P["Point (lat, lng)"] --> S1["S2: project to cube face"]
P --> H1["H3: project to icosahedron face"]
S1 --> S2["Quadtree subdivide, 30 levels"]
S2 --> S3["Order cells on Hilbert curve"]
S3 --> S4["64-bit cell ID, descendants contiguous"]
S4 --> S5["Range scans for containment"]
H1 --> H2["Hexagon grid, aperture 7"]
H2 --> H3["64-bit cell index, 16 resolutions"]
H3 --> H4["k-rings for neighbours and smoothing"]Worked example
A ride-hailing company computes surge multipliers every minute for a metro area of about 1,500 square km.
At H3 resolution 8 (0.74 square km per cell) the city has roughly 2,000 cells. Each of 50,000 requests and 300,000 driver pings per minute maps to a cell in about a microsecond, and a streaming job keeps two counters per cell. Per-cell ratios are noisy, so each cell is smoothed with its first ring (gridDisk(cell, 1), 7 cells), weighting neighbours equally because they are equidistant.
For 30,000 airport and stadium pickup geofences, S2 fits better. Each polygon is covered with at most 16 cells between levels 10 and 16, about 480,000 (cell ID, fence ID) rows. A pickup point's ancestor cell at each of those 7 levels is looked up, returning the few fences to check exactly with point-in-polygon, all well under a millisecond in memory.
Covering size is the tuning knob: at 16 cells per fence about 12 percent of lookups yield a false-positive candidate; 64 cells cut that to 3 percent but quadruple the index.
In the wild
- Uber, 2018: hexagons chosen over postal codes and hand-drawn zones. Uber prices surge by measuring supply and demand inside H3 hexagons in each city. Uber weighed the alternatives and set aside postal-code areas, whose shapes and sizes are irregular, and zones drawn by city operations, which need frequent updating. Uber chose hexagons because every neighbour is equidistant, which keeps smoothing and radius approximations simple; compacting one example region cut 10,633 resolution-6 cells to 901 mixed-resolution cells (Uber).
- Tinder, 2019: S2 cells as shard keys. Tinder's recommendations ran on one Elasticsearch index with five shards, and costs climbed. Because searches are capped at 100 miles, it split users into geoshards: it computed a load score per S2 cell at level 7 (about 45 miles) and level 8 (about 22.5 miles), walked cells in Hilbert order, and cut a new shard whenever a shard's load filled up, so shards stay compact on the map. 40 to 100 geoshards balanced p50 to p99 latency, and the result handled 20 times the computation of the single index. One trap: Node, Java and Go libraries produced different 64-bit IDs for the same cell, so Tinder stored the string token instead (Tinder Engineering).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| S2 | Exact hierarchy, contiguous descendant ranges, flexible coverings, low distortion | Square cells have unequal neighbour distances, harder to visualise | Indexing regions, containment, range-scan storage |
| H3 | Equidistant neighbours, uniform-looking cells, great for aggregation | Approximate hierarchy, 12 pentagons per level, no exact parent containment | Analytics, heatmaps, smoothing, supply and demand bucketing |
| Geohash | Readable strings, works anywhere | Large distortion near poles, Z-curve jumps, 8-neighbour expansion | Simple proximity search, small scope |
| R-tree on raw geometry | Exact shapes, no discretisation | Needs a spatial engine, hard to shard | Complex polygons in a single database |
Pitfalls in production
- Mixing resolutions without a rule. Teams store H3 resolution 8 in one table and 9 in another and then join them. Pick canonical resolutions per use case and convert explicitly with parent/child functions.
- Treating H3 parents as exact containers. Truncating to a parent can misassign points near hexagon edges. Use S2 when containment must be exact.
- Ignoring pentagons.
gridDiskdoes not fail near a pentagon, but it can leave zero entries in its output, which you must skip.gridDiskUnsafe,gridRingUnsafe,gridDistanceandgridPathCellsreturn errors when pentagonal distortion gets in the way (H3 traversal API). Handle both cases and test with a pentagon cell ID. - Coverings that are too coarse or too fine. Too few cells floods queries with false positives; too many inflates the index. Measure false-positive rate and index size together and tune max cells.
Leadership lens
- Choose by question type: "what is inside this region" favours S2; "how much happens in each area" favours H3. Many companies use both.
- Make the grid choice and resolutions a documented platform decision. Changing resolution later means backfilling every historical aggregate.
- Cell IDs are a good shard key for spatial data, but dense cells become hot. Plan to split by a finer level in cities.
Check yourself
Why can S2 answer "is this point inside this cell" with a single integer range check?
Show answer
Cells are numbered along a Hilbert curve with a level-terminating bit, so all leaf descendants of a cell fall in one contiguous ID range, and the point's leaf ID is inside the cell exactly when it is inside that range.
Why do H3 users prefer hexagons for smoothing demand across neighbouring areas?
Show answer
All six neighbours share an edge and are equidistant from the centre, so a k-ring is an unbiased neighbourhood; square grids mix edge and corner neighbours at different distances.
What do you lose by using H3's parent function as a containment test?
Show answer
Exactness. Seven child hexagons only approximate their parent's area, so points near the boundary can belong to a child whose parent ID is not the hexagon that geometrically contains them.
Sources and further study
Primary
- Moon, Jagadish, Faloutsos and Saltz, Analysis of the Clustering Properties of the Hilbert Space-Filling Curve (IEEE TKDE, 2001): why S2 picked the Hilbert curve over the Z-order curve.
- Uber Engineering: H3, Uber's Hexagonal Hierarchical Spatial Index (2018): the design rationale from H3's authors.
Systems docs
- S2 cell statistics: minimum, maximum and average cell area at every level from 0 to 30.
- H3 traversal API:
gridDiskand friends, with the exact pentagon caveats per function. - H3 v3 to v4 migration guide: the full table of renamed functions.
Books
- System Design Interview, Volume 2, Alex Xu and Sahn Lam, chapter 1 "Proximity Service": Google S2 next to geohash and quadtrees as candidate indexes.
- System Design Interview, Volume 2, chapter 2 "Nearby Friends": moving users and high-rate location updates.
- Designing Data-Intensive Applications (2nd ed.), Kleppmann and Riccomini, chapter 7 "Sharding": key-range sharding and hot spots, the problems S2 cell IDs inherit as shard keys.
Other curricula
- donnemartin/system-design-primer: does not cover S2, H3 or spatial grids; geography appears only as a sharding dimension.
Watch
- H3: Tiling the Earth with Hexagons (Uber Engineering): the design reasoning behind hexagons, aperture 7, and the icosahedron projection.
- Designing a location database: QuadTrees and Hilbert Curves (Gaurav Sen): why Hilbert curves preserve locality, the idea at the core of S2.
Free resources
- S2 Geometry: official site with an overview of the library and its design goals.
- S2 Cell Hierarchy: the cube projection, Hilbert ordering, cell ID bit layout, and per-level statistics.
- S2 Region Coverings: how coverer parameters change the shape and size of a covering.
- H3 documentation: core concepts, API reference, and the pentagon and hierarchy caveats.
- H3 resolution table: average cell area and edge length for every resolution.
- H3 indexing highlights: why hexagons and how the hierarchical index behaves.