Building proximity search against millions of GPS points, you eventually hit the same wall: a brute-force distance scan is too slow, and raw lat/lng coordinates don't index well. Spatial grid systems solve this by snapping coordinates to pre-computed cells — fast lookup, clean aggregation, shareable identifiers. Geohash has been doing this since 2008. H3 took hexagons mainstream when Uber open-sourced it in 2018. S2 is what Google Maps runs on internally. The three are not interchangeable, and the differences that seem academic in a prototype become consequential when your data crosses a cell boundary or spans multiple continents.
What Are Spatial Grid Indexing Systems?
Spatial grid indexing systems divide the Earth's surface into discrete cells, assigning each cell a unique identifier. Instead of storing raw coordinates, you store a cell ID — enabling fast lookups, range queries, and data aggregation by simply comparing string prefixes or integer values. The core tradeoff is between cell uniformity, implementation simplicity, and query performance.
All three systems emerged from different engineering cultures. Geohash was invented by Gustavo Niemeyer in 2008 as a public domain geocoding system — it's the simplest by design. S2 was developed at Google around 2011 as a geometry library for internal mapping infrastructure. H3 was open-sourced by Uber in 2018 to solve driver/passenger matching at city scale.
Their cell shapes reflect these origins: Geohash produces rectangles, S2 produces spherical quadrilaterals (roughly square on a sphere), and H3 produces hexagons (with 12 pentagons to close the sphere).
How Each System Works
Each system's encoding approach shapes both its strengths and its failure modes in production.
H3 projects the Earth onto a regular icosahedron (a 20-faced polyhedron), then subdivides each face into hexagons. The result is 15 resolution levels where each cell subdivides into roughly 7 children. Cell IDs are 64-bit integers. The hexagonal shape means every cell has exactly 6 edge-sharing neighbors — no corner-sharing ambiguity.
Geohash interleaves the binary representations of latitude and longitude bits, then encodes the result as a Base32 string. This is why Geohash strings are human-readable: u4pruydqqvj uniquely identifies a 3 m × 3 m patch in the center of Paris. Precision is controlled by string length (1–12 characters). The prefix property — u4pru is always inside u4pr — makes range queries trivially easy.
S2 unfolds the Earth's surface onto a cube's six faces, applies a Hilbert space-filling curve across each face, then maps positions to 64-bit integers. The Hilbert curve preserves spatial locality: nearby points on Earth produce nearby integers. S2 has 31 resolution levels and supports arbitrary polygon coverage via S2RegionCoverer, which approximates any shape with the minimum number of cells needed.
Precision and Coverage — A Comparison Table
The three systems use incompatible precision scales — H3's 15 resolutions, Geohash's 12 character lengths, and S2's 31 levels don't map to each other. The table below aligns them by approximate cell area at three practical scales: city-level aggregation, neighborhood routing, and building-level precision.
The degree-of-longitude shrinkage follows a cosine curve and becomes severe faster than most developers expect. At 30° latitude (Los Angeles, Cairo, Shanghai), one degree of longitude covers 96.5 km versus 110.6 km for a degree of latitude — a 14.9% east-west shortfall. By 60° (Stockholm, Anchorage, Saint Petersburg), the ratio reaches 2:1: a degree of longitude is only 55.7 km wide. A 6-character Geohash cell that covers ~0.74 km² at the equator covers only ~0.37 km² at 60°N. If you're building a global service with uniform proximity thresholds (e.g., "within 500 meters"), Geohash at a fixed precision level will give inconsistent results by latitude — you'd need to use longer hashes at lower latitudes and shorter ones near the poles to get equivalent real-world coverage, which complicates your query logic. H3 and S2 both compensate for spherical distortion: H3 at resolution 9 varies less than 3% in area across all latitudes.
| Scale | H3 Resolution | H3 Cell Area | Geohash Length | Geohash Cell Area | S2 Level | S2 Cell Area |
|---|---|---|---|---|---|---|
| City | 5 | ~253 km² | 4 chars | ~780 km² | 10 | ~325 km² |
| Neighborhood | 9 | ~0.11 km² | 6 chars | ~0.74 km² | 15 | ~0.32 km² |
| Building | 11 | ~0.0022 km² | 7 chars | ~0.023 km² | 18 | ~0.005 km² |
Neighbor Search and Boundary Effects
Neighbor queries are the most common spatial index operation: "given this cell, find all adjacent cells." The three systems handle this with very different ergonomics.
H3 has the cleanest neighbor model. Every hexagon has exactly 6 edge-sharing neighbors — no corner neighbors, no ambiguity. The gridDisk(cell, k) function returns all cells within k rings in a single call. A k=1 disk returns 7 cells (including center); k=2 returns 19. Because hexagons have equal edge length on all sides, distance queries using ring count are geometrically consistent.
Geohash has 8 neighbors (4 edges + 4 corners). For most cells this works fine. The notorious problem is at grid boundaries: two cells that are geographically adjacent can have Geohash strings that share no prefix. For example, cells straddling the Prime Meridian (0° longitude) or the equator can have completely different hashes. The classic symptom: a query returns results on one side of a boundary but silently misses points just across it.
A well-documented real-world example: La Roche-Chalais in southwestern France has Geohash prefix u000, while Pomerol — a town just 30 km away — has prefix ezzz. These two nearby locations share no common prefix at any length because a major cell boundary runs directly between them. A prefix-based proximity query for "points near La Roche-Chalais" would silently return zero results from Pomerol. The same problem occurs at the Greenwich Meridian (0° longitude): two GPS points 10 meters apart on opposite sides of the Prime Meridian get hashes starting with e (west side) and s (east side) — no shared prefix, no shared index range. The standard fix is to always query the target cell plus its 8 neighbors, then filter by actual Haversine distance. Redis's GEOSEARCH command handles this internally, which is one reason native database support is preferable to rolling your own Geohash proximity logic.
S2 uses a Hilbert curve which provides strong locality guarantees — nearby points on Earth map to nearby integers in the index. However, "nearby in Hilbert space" and "spatially adjacent" aren't always the same. The six cube faces introduce seams, and computing neighbors across face boundaries requires careful handling. S2's S2CellId::EdgeNeighbors() handles this correctly, but it's more implementation work than H3's gridDisk.
If your data spans geographic boundaries — coastlines, national borders, the date line — H3's uniform hexagonal topology is the safer default. Rectangular grid systems like Geohash have seams at predictable longitudes and latitudes, and debugging a boundary miss in production is unpleasant.
When to Use Each System
If you're already running Redis or Elasticsearch, use Geohash — proximity search works out of the box with zero extra code. If you need global consistency or hexagonal visualization, H3. Only reach for S2 if you need to index arbitrary polygons or are working alongside existing Google infrastructure. The table covers the common scenarios:
| Use Case | Recommended | Reason |
|---|---|---|
| Redis GEO commands / Elasticsearch geo_point | Geohash | Native built-in support, zero setup |
| Ride-sharing / food delivery demand heatmaps | H3 | Consistent cell size + hexagonal visualization |
| Global data aggregation (climate, telemetry) | H3 | Eliminates polar latitude distortion |
| Arbitrary polygon region indexing | S2 | S2RegionCoverer approximates any shape |
| Game grid systems (Pokémon GO uses this) | S2 | Niantic's original infrastructure choice |
| Quick prototypes and simple geofencing | Geohash | Lowest learning curve, human-readable IDs |
| Multi-resolution analysis (zoom in/out) | H3 | Clean parent/child hierarchy |
| PostGIS / spatial databases | Geohash | Standard GIS ecosystem support |
Ecosystem maturity often determines the choice before the geometry does. Geohash has been built into Redis since 2015 (GEOADD, GEORADIUS), and Elasticsearch's geo_point field type uses Geohash internally for grid aggregations. If your stack already includes either, Geohash integration requires almost no extra work.
H3 has strong Python (h3) and JavaScript (h3-js) libraries, a growing ecosystem of visualization tools (Deck.gl's H3HexagonLayer), and first-class support in DuckDB spatial extension. S2 has the most powerful geometry operations but the weakest JavaScript support — the main library is C++, with community-maintained ports to other languages.
Working with H3, Geohash, and S2 in Code
All three systems have JavaScript and Python libraries. The following examples cover the three operations you'll use most: encode a coordinate, find neighbors, and get the cell boundary polygon. For a detailed comparison of h3-js and ngeohash as npm packages — including bundle sizes, encoding benchmarks, and production gotchas — see our h3-js vs ngeohash guide.
Two library gotchas worth knowing before shipping:
ngeohash integer precision: the encode_int / decode_int functions are capped at 52 bits in JavaScript (standard IEEE 754 float64 safe integer range). For most use cases this is fine, but if you set bitDepth above 52, results silently lose precision. Use the string API (encode / decode) unless you have a specific reason to work with integers, and always pass the same bitDepth value consistently to both encode and decode calls.
h3-js cell ID representation: H3 cell IDs are 64-bit integers, which exceed JavaScript's safe integer range (2^53 − 1). The h3-js library returns them as strings by default (e.g., "8928308280fffff"). A common production bug: if your API serializes H3 IDs as JSON numbers instead of strings, some JSON parsers will silently round the value and corrupt the cell ID. Always treat H3 IDs as strings throughout your entire stack — in your database schema, API responses, and frontend code.
JavaScript
import { latLngToCell, gridDisk, cellToBoundary } from "h3-js";
import ngeohash from "ngeohash";
// --- H3 ---
// Encode coordinate to H3 cell (resolution 9 ≈ 0.1 km²)
const h3Cell = latLngToCell(37.7749, -122.4194, 9);
// → "8928308280fffff"
// Get all cells within 1 ring (7 cells including center)
const h3Neighbors = gridDisk(h3Cell, 1);
// Get cell boundary polygon (array of [lat, lng] pairs)
const h3Boundary = cellToBoundary(h3Cell);
// --- Geohash ---
// Encode coordinate to Geohash (precision 6 ≈ 0.74 km²)
const hash = ngeohash.encode(37.7749, -122.4194, 6);
// → "9q8yyk"
// Decode back to coordinate
const { latitude, longitude } = ngeohash.decode(hash);
// Get 8 neighbors (N, NE, E, SE, S, SW, W, NW)
const geohashNeighbors = ngeohash.neighbors(hash);
// Get cell bounding box [minLat, minLon, maxLat, maxLon]
const bbox = ngeohash.decode_bbox(hash);
Python
import h3
import geohash # pip install python-geohash
# --- H3 ---
cell = h3.latlng_to_cell(37.7749, -122.4194, 9)
neighbors = h3.grid_disk(cell, 1)
boundary = h3.cell_to_boundary(cell) # list of (lat, lng) tuples
# --- Geohash ---
hash_str = geohash.encode(37.7749, -122.4194, precision=6)
decoded_lat, decoded_lng = geohash.decode(hash_str)
neighbors = geohash.neighbors(hash_str) # dict with N/NE/E/SE/S/SW/W/NW keys
S2 in JavaScript requires a community port (s2-geometry npm package) which lags behind the C++ library. For serious S2 work in JS environments, the most practical approach is to call a Python/Java service or use Cloudflare Workers with WASM. In Python, the s2sphere library provides a clean interface:
import s2sphere
# Encode coordinate to S2 cell (level 15 ≈ 0.32 km²)
latlng = s2sphere.LatLng.from_degrees(37.7749, -122.4194)
cell_id = s2sphere.CellId.from_lat_lng(latlng).parent(15)
# Get 4 edge neighbors
neighbors = cell_id.edge_neighbors()
# Cover an arbitrary polygon with S2 cells
region_coverer = s2sphere.RegionCoverer()
region_coverer.min_level = 10
region_coverer.max_level = 15
covering = region_coverer.get_covering(some_s2_region)
You can experiment with Geohash and H3 encoding directly in the browser using KunYu's GeoHash encoder tool — no library installation needed.
FAQ
Is H3 better than Geohash?
For new projects with no existing infrastructure constraints, H3 is the better default: uniform cell sizes, cleaner neighbor queries, and no boundary bugs. Geohash wins when you need Redis GEO commands or Elasticsearch aggregations out of the box, or when a human-readable ID matters — Geohash strings are easy to read in logs; H3 IDs like 8928308280fffff are not.
Does Redis support H3?
Redis has no native H3 support. The built-in GEO commands (GEOADD, GEORADIUS, GEOSEARCH) use Geohash internally. To use H3 with Redis, encode coordinates to H3 cell IDs in your application layer and store them as regular string keys or in sorted sets. As of Redis 7.x, there is no plan to add H3 primitives to the core server.
What does S2 stand for?
S2 is short for Sphere² (sphere squared), referring to the mathematical concept of mapping a sphere onto a two-dimensional surface. The library was developed at Google and is used internally across Google Maps, Google Earth, and other Google geo products.
How many H3 cells cover the Earth at resolution 5?
At resolution 5, there are 2,016,842 H3 cells covering the Earth's surface (including the 12 pentagons that close the icosahedron). Each cell covers approximately 252.9 km², making this resolution useful for country-level or large metropolitan area analysis.
Can I convert between H3 and Geohash?
There is no direct mathematical conversion. The two grids are independent — an H3 hexagon at resolution 9 and a Geohash 6-character cell cover different areas and can only be approximately mapped. The practical approach is to decode either format to a lat/lng coordinate, then re-encode in the target format. Be aware that the resulting cell may not perfectly contain the original cell due to size and shape differences.
What is the maximum Geohash precision?
The Geohash specification supports up to 12 characters, yielding cells approximately 3.7 cm × 1.9 cm in size. In practice, most applications use 6–9 characters (1 km² down to ~7 m²). Beyond 9 characters, the precision exceeds what most GPS hardware can provide, and the long string keys create unnecessary storage and index overhead.