Discrete Global Grids Explained: H3, S2, Geohash and Why Cells Beat Coordinates

Problem statement

A coordinate pair makes a poor key. Two GPS fixes at the same café differ in the sixth decimal place, so they never compare equal. Group by them and you get one group per row; join on them and nothing matches. So every "how many events per area" question turns into a geometry job: build polygons, pick a CRS, run a spatial join.

A discrete global grid swaps the coordinate for the identifier of the cell that contains it. That identifier is a short string or a 64-bit integer. It can be grouped, sorted, hashed, stored in any database and joined with an ordinary equality. There is no CRS to choose, because the grid covers the whole Earth.

Four grids are in common use: H3, S2, geohash and Web Mercator quadkeys. They are not interchangeable. Measured on the same data:

  • Area. Quadkey tiles at zoom 12 cover 4.0× less ground at 60° than at the equator, and 131.7× less at 85°. H3 hexagons at resolution 5 vary 1.98×, and the variation does not follow latitude.
  • Edges. Every grid splits close neighbours across cell boundaries. Of 33,026 pairs of real places less than 100 m apart, 52.3% landed in different geohash-7 cells and 21.4% in different H3 resolution-9 cells.

Choosing a grid means choosing which of these properties you can live with.

Quick answer

A grid turns a coordinate into a key. Each system does it in one call:

import h3, s2sphere, pygeohash, mercantile

lat, lng = 51.5007, -0.1246                      # Westminster
print("H3 res 9     ", h3.latlng_to_cell(lat, lng, 9))
print("S2 level 13  ", s2sphere.CellId.from_lat_lng(
    s2sphere.LatLng.from_degrees(lat, lng)).parent(13).to_token())
print("geohash 7    ", pygeohash.encode(lat, lng, precision=7))
print("quadkey z15  ", mercantile.quadkey(mercantile.tile(lng, lat, 15)))
H3 res 9      89194ad14c3ffff
S2 level 13   487604c4
geohash 7     gcpuvpm
quadkey z15   031313131130102

Once every row has a key, aggregation is a plain groupby. Measured on 235,735 GeoNames places at H3 resolution 5, indexing plus counting took 0.14 s. A spatial join of the same points against drawn hexagons took 0.23 s, not counting the time to build the hexagons. It also disagreed with the index on 21 points: 18 matched a neighbouring hexagon and 3 matched none. Every one of the 18 was within 1.7 m of a drawn edge.

Table comparing H3, S2, geohash and quadkeys by cell shape, construction, key format and measured area spread.
The two grids built on a polyhedron keep area within about 2×. The two built on latitude and longitude lose area towards the poles.

Step-by-step solution

1. Treat a cell key as a coordinate rounded onto the sphere

floor(lon / 0.1) is already a grid: it rounds a coordinate onto a lattice and the rounded value becomes the group key. A discrete global grid does the same rounding on the sphere, then gives the result a compact name.

Every system has levels. A coarser level means bigger cells and a shorter or smaller key, so each system also has a parent-and-child relationship. How exactly those levels nest varies by system, and that difference matters later.

2. Know how each of the four is built

System Cell shape Built from Levels Key
H3 hexagon, plus 12 pentagons icosahedron, aperture 7 0–15 64-bit integer or 15 hex characters
S2 quadrilateral cube projected onto the sphere, quadtree 0–30 64-bit integer or hex token
Geohash latitude–longitude rectangle alternating halving of longitude and latitude one per character base-32 string
Quadkey Web Mercator square tile quadtree of map tiles one per zoom base-4 string

H3 and S2 project a polyhedron onto the sphere, so their cells stay roughly the same size everywhere. Geohash and quadkeys subdivide a flat map, which makes the arithmetic trivial but shrinks the cells towards the poles. Quadkeys stop entirely at 85.05° north and south, where Web Mercator ends.

3. Compare how much the cell area varies

Measured within one level of each system:

system    level      area within the level
H3        res 5      hexagons 153.8–305.1 km² (1.98×); 12 pentagons at 127.8 km²
S2        level 7    all 98,304 cells, 3,175–6,529 km² (2.06×)
S2        level 10   100,000-cell sample, 48.9–102.0 km² (2.09×)
geohash   5 chars    23.9 km² at the equator, 11.9 at 60° (2.0×), 2.1 at 85°
quadkey   zoom 12    95.5 km² at the equator, 23.9 at 60° (4.0×), 0.7 at 85°

The spread alone is only half the story. The other half is where the variation sits. Geohash and quadkey cells shrink with latitude, so a map of counts per cell has a latitude gradient built into it. H3 and S2 cells vary with their position on a polyhedron face. By latitude band, the mean H3 resolution-5 area only moves between 247 and 265 km².

4. Compare the neighbours

A hexagon has one kind of neighbour. A square has two: four that share an edge and four that share only a corner.

H3 res 7, London       6 neighbours, centres 2.242–2.425 km away   ratio 1.08
geohash 6, London      8 neighbours, centres 0.611–0.975 km away   ratio 1.60

That 1.60 ratio has a practical cost. "The cell and its neighbours" covers a different distance in different directions, so a smoothing or buffer built from square neighbours is not round.

5. Expect the edge problem in every grid

However small the cells, two points close together can sit on opposite sides of a boundary. Measured on 326,856 places in Great Britain, France and Germany:

                          pairs < 100 m apart    pairs < 500 m apart
                          split across an edge   split across an edge
H3 res 9 (edge 201 m)          21.4%                  81.8%
H3 res 8 (edge 531 m)           9.4%                  42.1%
geohash 7 (153 m tall)         52.3%                  95.3%
geohash 6 (611 m tall)         10.2%                  48.7%
quadkey z15                     8.8%                  43.1%

With geohash it gets worse, because a shared prefix looks like closeness and is not. Two points 13.9 m apart on either side of the Greenwich meridian encode as gcpuzgrb and u10hb520, which share no characters at all.

6. Group and join on the key, not the geometry

import h3
import pandas as pd

cities["cell"] = [h3.latlng_to_cell(y, x, 5)
                  for y, x in zip(cities.lat.tolist(), cities.lon.tolist())]
counts = cities.groupby("cell").size()

Nothing here needs a CRS, a geometry column or a spatial index. The key is a string, so the same operation works in pandas, DuckDB, BigQuery, Spark or a key-value store. Two datasets keyed the same way can be joined with merge.

7. Pick one system per project and record the level

  • H3 for analysis: aggregation, smoothing and neighbourhoods, where near-uniform hexagons matter.
  • S2 for covering regions with cells of mixed sizes, and wherever the Google or BigQuery ecosystem already uses it.
  • Geohash when the store can only index strings and prefix scans are the query.
  • Quadkeys when the output really is Web Mercator tiles.

Keys from different systems, or from different levels of the same system, do not compare equal. Store the level beside the key, or put it in the column name.

Code examples

Example 1 — every system's key for a DataFrame

import time
import h3, s2sphere, pygeohash, mercantile
import pandas as pd

KEYS = {
    "h3": lambda y, x: h3.latlng_to_cell(y, x, 9),
    "s2": lambda y, x: s2sphere.CellId.from_lat_lng(
        s2sphere.LatLng.from_degrees(y, x)).parent(13).to_token(),
    "geohash": lambda y, x: pygeohash.encode(y, x, precision=7),
    "quadkey": lambda y, x: mercantile.quadkey(mercantile.tile(x, y, 15)),
}


def add_grid_keys(df, lat="lat", lon="lon", systems=KEYS):
    """Add one key column per grid system, all from the same coordinates."""
    out = df.copy()
    ys, xs = out[lat].tolist(), out[lon].tolist()
    for name, key in systems.items():
        started = time.perf_counter()
        out[name] = [key(y, x) for y, x in zip(ys, xs)]
        print(f"{name:8} {out[name].nunique():>8,} cells  "
              f"{time.perf_counter() - started:5.2f} s")
    return out

On the 235,735 places in GeoNames cities500:

h3        235,152 cells   0.17 s
s2        231,187 cells   1.36 s
geohash   235,501 cells   0.11 s
quadkey   231,926 cells   0.74 s

               name              h3       s2 geohash         quadkey
City of Westminster 89194ad145bffff 487604dc gcpuuyv 031313131130013
             London 89195da49b7ffff 487604cc gcpvj0u 031313131130100
          Edinburgh 8919727650fffff 4887c794 gcvwr3b 031133233211123

The speed gap comes from the implementations: H3's core is C, while s2sphere is pure Python. It tells you nothing about the grids themselves.

Example 2 — the ground area of a cell in each system

import math
import h3, s2sphere, pygeohash, mercantile

R = 6371.007180918475          # km — the sphere h3.cell_area uses


def rect_km2(south, north, west, east):
    """Exact spherical area of a latitude–longitude rectangle."""
    return (R ** 2 * math.radians(east - west)
            * abs(math.sin(math.radians(north)) - math.sin(math.radians(south))))


def cell_areas_km2(lat, lon, h3_res=5, s2_level=10, gh_precision=5, zoom=12):
    """Ground area of the cell containing one point, in each system."""
    cell = h3.latlng_to_cell(lat, lon, h3_res)
    s2_cell = s2sphere.Cell(s2sphere.CellId.from_lat_lng(
        s2sphere.LatLng.from_degrees(lat, lon)).parent(s2_level))
    gy, gx, dy, dx = pygeohash.decode_exactly(
        pygeohash.encode(lat, lon, precision=gh_precision))
    tile = mercantile.bounds(mercantile.tile(lon, lat, zoom))
    return {
        "h3": h3.cell_area(cell, "km^2"),
        "s2": s2_cell.exact_area() * R ** 2,
        "geohash": rect_km2(gy - dy, gy + dy, gx - dx, gx + dx),
        "quadkey": rect_km2(tile.south, tile.north, tile.west, tile.east),
    }

Along the meridian at 10.3° E:

 lat    h3 r5   s2 l10     gh 5      z12
 0.5    237.5     82.5     23.9     95.5
  30    287.6     87.9     20.7     71.6
  45    255.0     60.0     16.9     47.8
  60    180.1     84.1     11.9     23.9
  75    211.3     88.8      6.2      6.4

The last two columns fall steadily with latitude. The first two go up and down: the 60° hexagon is smaller than the one at 75°. For a density map this matters. Divide by the area of each cell, never by a single nominal area per level.

Example 3 — how often near neighbours are split

import math
import numpy as np
from scipy.spatial import cKDTree

EARTH_M = 6371008.8


def split_rate(lat, lon, key, distance_m):
    """Share of point pairs closer than distance_m that land in different cells."""
    lat, lon = np.asarray(lat), np.asarray(lon)
    phi, lam = np.radians(lat), np.radians(lon)
    unit = np.column_stack([np.cos(phi) * np.cos(lam),
                            np.cos(phi) * np.sin(lam), np.sin(phi)])
    chord = 2 * math.sin(distance_m / EARTH_M / 2)
    pairs = cKDTree(unit).query_pairs(chord, output_type="ndarray")
    a, b = pairs[:, 0], pairs[:, 1]
    moved = (lat[a] != lat[b]) | (lon[a] != lon[b])      # ignore duplicates
    a, b = a[moved], b[moved]
    used = np.unique(np.concatenate([a, b]))
    keys = {i: key(lat[i], lon[i]) for i in used.tolist()}
    split = sum(keys[i] != keys[j] for i, j in zip(a.tolist(), b.tolist()))
    return len(a), split / len(a)
H3 res 9   33,026 pairs within 100 m, 21.4% split across a cell edge
geohash 7  33,026 pairs within 100 m, 52.3% split across a cell edge

Run it on your own data at the distance your analysis cares about, before you trust a same-cell join. If the split rate is not close to zero, the join also has to look in neighbouring cells.

Explanation

Why a key is cheaper than a geometry

A spatial join builds geometries, builds an index over one side, finds candidates by bounding box and then tests each candidate exactly. An equality join on a key hashes one column and looks it up. Only the second one runs in any system that can join two tables. That is why cell keys turn up in data warehouses, stream processors and key-value stores that have no geometry type.

The key also removes the CRS question. A GeoPandas dwithin join needs a projected CRS that suits the whole dataset. A global dataset has no such CRS, but it has one H3 grid.

Why no grid has equal areas and identical cells

A sphere can be tiled with identical regular polygons only as the five Platonic solids, and the finest of those has 20 faces. Any finer grid has to give something up: equal area, equal shape, or simple arithmetic.

Geohash and quadkeys keep the arithmetic. Their keys are just bit-interleaved longitude and latitude, or tile numbers, and the cost is area that collapses towards the poles. H3 and S2 keep the shape nearly uniform and accept a bounded area spread of about 2×. Equal-area designs such as ISEA and rHEALPix exist but are far less common in Python tooling.

Why hexagons make neighbourhoods simpler

Every neighbour of a hexagon shares a full edge, and its centre is about the same distance away: a ratio of 1.08 measured in London against 1.60 for geohash. So "within k steps" traces an almost circular shape, and a smoothing kernel treats all six directions alike.

The price is the hierarchy. Seven hexagons cannot tile a larger hexagon exactly, so H3 children stick out past their parent's edge. The S2 quadtree and geohash prefixes nest perfectly.

Why the edge problem cannot be designed away

A partition has boundaries, and a pair of points closer together than the cell size will sometimes straddle one. The split rate grows with the ratio of pair distance to cell size. At H3 resolution 9 it went from 21.4% at 100 m to 81.8% at 500 m.

Cell shape matters much less than cell size. Near 50° N, three cells of about the same area split 100 m pairs at about the same rate: a quadkey z15 tile (0.62 km²) split 8.8%, an H3 resolution-8 hexagon (0.65 km²) 9.4%, and a six-character geohash (0.48 km²) 10.2%. No shape brings the rate to zero. The only real fix is to search the neighbouring cells as well, then check the exact distance.

Why a join against drawn hexagons disagrees with the index

cell_to_boundary gives the cell's vertices. A GIS then joins those vertices with straight lines in longitude and latitude, but H3's true edges are computed on the sphere. At resolution 5, the midpoints of the two versions of an edge were 0.8 m apart at the median and 3.1 m at most.

The 21 disagreeing points out of 235,735 were exactly the ones in that sliver. 18 fell inside a neighbour's drawn outline. The other 3 fell inside the drawn outline of a cell that no point had been indexed to, so they matched nothing. The index is the authority. The drawn polygon is an approximation for display.

Bar chart of the largest-to-smallest cell area ratio within one level for H3, S2, geohash and quadkeys.
For geohash and quadkeys the ratio is a function of latitude, so a count map built on them has a gradient that is not in the data.

Edge cases or notes

Two panels: points 13.9 m apart across the Greenwich meridian with unrelated geohashes, and a hexagon whose six neighbours catch a point across the edge.
Comparing prefixes finds nothing across a geohash boundary. Searching a ring of neighbours does.
  • Quadkeys end at 85.05°. Web Mercator does not reach the poles, so anything beyond that latitude has no tile.
  • A shared geohash prefix is not proximity. Points 13.9 m apart across the Greenwich meridian share zero characters, and so do points 22.2 m apart across the equator.
  • Geohash cells are not square. At six characters a cell is 0.611 km tall and 1.222 km wide at the equator. Odd and even precisions alternate between near-square and 2:1.
  • H3 has 12 pentagons at every resolution. They have five neighbours and all sit in the ocean. 31 of 13.46 million GeoNames points fell inside a resolution-5 pentagon.
  • Store the level with the key. An H3 index carries its resolution, but a geohash only carries its length, and a quadkey's length is its zoom.
  • Integers are cheaper than strings. Two million H3 keys took 46.0 MB as Python strings and 16.0 MB as uint64.
  • Drawn cells crossing the antimeridian wrap around the map. 1,547 H3 resolution-5 cells span more than 180° of longitude when their vertices are joined naively.
  • Keys do not convert between systems. Going from a geohash to an H3 cell means going back through a coordinate, and you lose whatever precision the first key had already thrown away.

FAQ

What is a discrete global grid system?

A way of dividing the whole Earth into cells with unique identifiers, at several levels of detail. It turns a coordinate into a key that can be grouped, joined and stored without geometry or a CRS.

Is H3 better than geohash?

For analysis, usually yes. Its hexagons vary 1.98× in area with no latitude trend, and their neighbours are almost equidistant. Geohash is simpler and supports prefix scans, but its cells lose area towards the poles and a shared prefix does not mean two points are close.

Are H3 cells equal area?

No. At resolution 5 the hexagons range from 153.8 to 305.1 km² and the pentagons are 127.8 km². Divide counts by each cell's own area when densities matter.

Why do two nearby points get different cell keys?

They are on opposite sides of a cell edge. Of pairs of places less than 100 m apart, 21.4% were split at H3 resolution 9 and 52.3% at geohash precision 7.

Can I convert a geohash to an H3 cell?

Not directly. Decode the geohash to its centre and index that point, keeping in mind that the geohash already discarded the position inside its cell.

Do I still need a CRS?

Not to build keys, group or join. You do need one when you draw the cells on a map, or measure distances and areas outside what the grid library provides.