Geohash Explained: Prefixes, Precision and the Edge Problem

On this page

Problem statement

A geohash turns a coordinate into a short string — gcpuvp for central London — with a property that looks like magic: points that share a prefix are in the same cell. That makes proximity look like string matching, which every database, key-value store and search index is good at.

The magic has a limit that most uses of geohash eventually hit. Two points 9 metres apart on either side of the Greenwich meridian encode as gcpuzgrbw and u10hb5209, and share no characters at all.

That is not an exotic corner. Measured on 107,956 distinct GeoNames locations in Great Britain, 22.8% of true nearest neighbours fall in a different precision-5 cell, and at precision 6 the figure is 74.3%. A proximity search that looks only inside one geohash cell misses a large share of the answer, and the loss grows as the cells get finer.

Quick answer

Encode, then search the cell and its eight neighbours, never the cell alone:

import pygeohash as pgh

home = pgh.encode(51.5007, -0.1246, precision=6)       # 'gcpuvp'
north, south = pgh.get_adjacent(home, "top"), pgh.get_adjacent(home, "bottom")
block = [home, north, south,
         pgh.get_adjacent(home, "right"), pgh.get_adjacent(home, "left"),
         pgh.get_adjacent(north, "right"), pgh.get_adjacent(north, "left"),
         pgh.get_adjacent(south, "right"), pgh.get_adjacent(south, "left")]
print(block)

Measured on the British points, a radius search of 500 m at precision 5 found 92.9% of the true neighbours by looking in the home cell, and 100.0% by looking in the 3×3 block.

Geohash precision levels 4 to 8 with cell width and height in kilometres. what each geohash precision covers on the groundprecisionwidth at 0°heightwidth at 60°439.092 km19.546 km19.546 km54.887 km4.887 km2.443 km61.222 km0.611 km0.611 km70.153 km0.153 km0.076 km80.038 km0.019 km0.019 kmheights never change with latitude; widths shrink with cos(latitude)Odd precisions are square at the equator, even ones twice as wide as tall spatialworkflow.io
Odd precisions give square cells at the equator; even precisions give cells twice as wide as they are tall.

Alternate bisections, base-32 and prefixes

1. Bisect longitude and latitude alternately

A geohash is built by repeated halving. The first bit says whether the longitude is in the western or eastern half of −180..180; the second says whether the latitude is in the southern or northern half of −90..90; the third halves the chosen longitude range again, and so on.

The bits interleave, longitude first. After 30 bits the point is known to within a small rectangle, and every earlier bit describes a larger rectangle containing it.

2. Group the bits into base-32 characters

Every five bits become one character from the alphabet 0123456789bcdefghjkmnpqrstuvwxyz (no a, i, l or o). So precision 6 is 30 bits: 15 for longitude, 15 for latitude.

At odd precisions the longitude gets one more bit than the latitude. That is why cell shapes alternate, as the next step's table shows.

3. Know what each precision means on the ground

Computed from the bit counts, with a degree of longitude shrinking by cos(latitude):

precision   width at equator   height      width at 60°
    4          39.092 km       19.546 km     19.546 km
    5           4.887 km        4.887 km      2.443 km
    6           1.222 km        0.611 km      0.611 km
    7           0.153 km        0.153 km      0.076 km
    8           0.038 km        0.019 km      0.019 km

Heights never change with latitude; widths halve by 60°. A precision-6 cell is 1.2 km × 0.6 km at the equator and a square 0.6 km × 0.6 km in Oslo.

4. Read a prefix as containment

gcpuvp is inside gcpuv, which is inside gcpu, and so on. A LIKE 'gcpuv%' query, a prefix scan in a key-value store or a range scan on a sorted string column returns every point inside that cell — exactly, and cheaply.

This is the property worth having. Everything that follows is about what it does not promise.

5. Do not read a shared prefix as nearness

A shared prefix proves two points are in the same cell. A different prefix proves nothing, because nearby points on opposite sides of a cell boundary are in different cells — and at a high-level boundary they differ from the very first character.

Measured among British nearest-neighbour pairs less than 1 km apart, 15.45% share fewer than five characters and 2.79% share fewer than four. Forty-one pairs share none, with a median separation of 477 m: they straddle the prime meridian, where the first bit flips.

6. Search the 3×3 block

The fix is to add the eight neighbouring cells to every lookup. A neighbour is computed from the cell itself, so crossing the meridian is handled: the eastern neighbour of gcpuzg is u10hb5.

Measured, 2,000 radius searches of 500 m over the British points returned every true neighbour — 0 missed of 6,570 — in 0.02 ms each, against 2.26 ms for a brute-force distance scan.

7. Choose the precision from the search radius

The 3×3 block guarantees completeness only when the radius is no larger than the cell's smaller dimension at the data's highest latitude. For the GeoNames points in Great Britain, which reach 61.62° north, that is 2.32 km at precision 5.

At precision 6 a 1 km search found only 93.7% of neighbours even with the 3×3 block, because the cells were smaller than the radius. Too fine is as broken as a single-cell lookup.

Share of nearest neighbours in a different geohash cell at precisions 4 to 8. nearest neighbour in a different cell · 107,956 British pointsprecision 44.4%precision 522.8%precision 674.3%precision 795.7%precision 899.2%0100%Median nearest-neighbour distance was 606 m spatialworkflow.io
The finer the cell, the more neighbours live next door rather than at home.

Cell sizes, 3x3 blocks and radius searches over Britain

The data used on this page

The points are GeoNames: the Great Britain file (GB.zip), reduced to distinct coordinates, and for the global comparison a 2,000,000-point random sample of the full allCountries dump. Both come from download.geonames.org and are rebuilt daily, so counts drift a little between runs.

import os
import zipfile

import numpy as np
import pandas as pd
import requests


def fetch(name):
    if not os.path.exists(name):
        with requests.get(f"https://download.geonames.org/export/dump/{name}", stream=True, timeout=600) as r:
            r.raise_for_status()
            with open(name, "wb") as out:
                for chunk in r.iter_content(1 << 20):
                    out.write(chunk)


def read_points(name):
    with zipfile.ZipFile(name) as z, z.open(name.replace(".zip", ".txt")) as f:
        return pd.read_csv(f, sep="\t", header=None, usecols=[4, 5], quoting=3, names=["lat", "lon"])


fetch("GB.zip")
fetch("allCountries.zip")
gb = read_points("GB.zip").drop_duplicates().reset_index(drop=True)
if not os.path.exists("global_sample.parquet"):
    read_points("allCountries.zip").drop_duplicates().sample(2_000_000, random_state=0) \
        .to_parquet("global_sample.parquet")
world = pd.read_parquet("global_sample.parquet")
print(f"Great Britain: {len(gb):,} distinct locations, up to {gb.lat.max():.2f}° N; global sample {len(world):,}")
Great Britain: 107,956 distinct locations, up to 61.62° N; global sample 2,000,000

Example 1 — cell size at a latitude

import math


def geohash_cell_size(precision, lat=0.0):
    """Width and height of a geohash cell in km at a given latitude."""
    lon_bits = math.ceil(5 * precision / 2)
    lat_bits = math.floor(5 * precision / 2)
    km_per_degree = math.pi * 6371.0088 / 180
    width = 360 / 2 ** lon_bits * km_per_degree * math.cos(math.radians(lat))
    height = 180 / 2 ** lat_bits * km_per_degree
    return round(width, 3), round(height, 3)
import pygeohash as pgh

print(geohash_cell_size(5), geohash_cell_size(5, 51.5), geohash_cell_size(6, 51.5))
print("precision   width at equator   height      width at 60°")
for p in range(4, 9):
    w, h = geohash_cell_size(p)
    print(f"{p:5}       {w:9.3f} km      {h:7.3f} km   {geohash_cell_size(p, 60)[0]:8.3f} km")

rng = np.random.default_rng(0)
agree = 0
for p in range(5, 9):
    for lat, lng in zip(rng.uniform(-80, 80, 10_000), rng.uniform(-180, 180, 10_000)):
        _, _, lat_err, lng_err = pgh.decode_exactly(pgh.encode(lat, lng, precision=p))
        agree += (np.isclose(2 * lng_err, 360 / 2 ** math.ceil(5 * p / 2))
                  and np.isclose(2 * lat_err, 180 / 2 ** math.floor(5 * p / 2)))
print(f"bit counts agree with pygeohash's own cells: {agree:,} of 40,000")
(4.887, 4.887) (3.042, 4.887) (0.76, 0.611)
precision   width at equator   height      width at 60°
    4          39.092 km       19.546 km     19.546 km
    5           4.887 km        4.887 km      2.443 km
    6           1.222 km        0.611 km      0.611 km
    7           0.153 km        0.153 km      0.076 km
    8           0.038 km        0.019 km      0.019 km
bit counts agree with pygeohash's own cells: 40,000 of 40,000

The bit counts are checked against pygeohash itself, cell by cell.

Example 2 — the 3×3 block

import pygeohash as pgh


def neighbours_3x3(gh):
    """The cell and its eight neighbours, crossing prefix boundaries correctly."""
    north, south = pgh.get_adjacent(gh, "top"), pgh.get_adjacent(gh, "bottom")
    return [gh, north, south, pgh.get_adjacent(gh, "right"), pgh.get_adjacent(gh, "left"),
            pgh.get_adjacent(north, "right"), pgh.get_adjacent(north, "left"),
            pgh.get_adjacent(south, "right"), pgh.get_adjacent(south, "left")]
from importlib.metadata import version

print(neighbours_3x3("gcpuvp"))
print(neighbours_3x3("gcpuzg")[:5])
print("pygeohash", version("pygeohash"))
['gcpuvp', 'gcpvj0', 'gcpuvn', 'gcpuvr', 'gcpuuz', 'gcpvj2', 'gcpvhb', 'gcpuvq', 'gcpuuy']
['gcpuzg', 'gcpuzu', 'gcpuzf', 'u10hb5', 'gcpuze']
pygeohash 3.5.1

Note how many of London's neighbours change the fourth or fifth character. In pygeohash 3.x, get_adjacent accepts the directions "top", "bottom", "left" and "right".

Example 3 — a radius search that cannot silently miss

import math
from collections import defaultdict

import numpy as np
import pygeohash as pgh


class GeohashIndex:
    """Radius search over points using geohash buckets plus an exact check."""

    def __init__(self, lats, lngs, precision):
        self.lats = np.asarray(lats, dtype=float)
        self.lngs = np.asarray(lngs, dtype=float)
        self.precision = precision
        self.buckets = defaultdict(list)
        for i, (lat, lng) in enumerate(zip(self.lats, self.lngs)):
            self.buckets[pgh.encode(lat, lng, precision=precision)].append(i)
        width, height = geohash_cell_size(precision, float(np.abs(self.lats).max()))
        self.safe_radius_km = min(width, height)

    def within(self, lat, lng, radius_km):
        if radius_km > self.safe_radius_km:
            raise ValueError(f"radius {radius_km} km exceeds {self.safe_radius_km} km; "
                             f"a 3x3 search at precision {self.precision} could miss points")
        home = pgh.encode(lat, lng, precision=self.precision)
        candidates = np.array([i for cell in neighbours_3x3(home) for i in self.buckets.get(cell, ())], dtype=int)
        if len(candidates) == 0:
            return candidates
        la1, lo1 = math.radians(lat), math.radians(lng)
        la2, lo2 = np.radians(self.lats[candidates]), np.radians(self.lngs[candidates])
        a = np.sin((la2 - la1) / 2) ** 2 + math.cos(la1) * np.cos(la2) * np.sin((lo2 - lo1) / 2) ** 2
        distance = 2 * 6371.0088 * np.arcsin(np.sqrt(a))
        return candidates[distance <= radius_km]
import time


def haversine_km(lat, lng, lats, lngs):
    la1, lo1, la2, lo2 = map(np.radians, (lat, lng, lats, lngs))
    a = np.sin((la2 - la1) / 2) ** 2 + np.cos(la1) * np.cos(la2) * np.sin((lo2 - lo1) / 2) ** 2
    return 2 * 6371.0088 * np.arcsin(np.sqrt(a))


t = time.perf_counter()
index = GeohashIndex(gb.lat, gb.lon, precision=5)
print(f"built index over {len(gb):,} points in {time.perf_counter() - t:.2f}s, "
      f"{len(index.buckets):,} buckets, safe radius {index.safe_radius_km} km")
queries = gb.sample(2000, random_state=1)
t = time.perf_counter()
found = [set(index.within(a, b, 0.5)) for a, b in zip(queries.lat, queries.lon)]
t_index = (time.perf_counter() - t) / len(queries)
t = time.perf_counter()
truth = [set(np.flatnonzero(haversine_km(a, b, index.lats, index.lngs) <= 0.5))
         for a, b in zip(queries.lat, queries.lon)]
t_brute = (time.perf_counter() - t) / len(queries)
print(f"2,000 queries at 500 m: index {t_index * 1000:.2f} ms each, brute force {t_brute * 1000:.2f} ms each; "
      f"missed {sum(len(t - f) for f, t in zip(found, truth)):,} of {sum(map(len, truth)):,}")
try:
    index.within(51.5, -0.12, 5)
except ValueError as e:
    print("ValueError:", e)
built index over 107,956 points in 0.05s, 19,034 buckets, safe radius 2.322 km
2,000 queries at 500 m: index 0.02 ms each, brute force 2.26 ms each; missed 0 of 6,570
ValueError: radius 5 km exceeds 2.322 km; a 3x3 search at precision 5 could miss points

The guard is the useful part. A geohash search that returns fewer results than it should looks exactly like a correct one, so the class refuses the radii it cannot answer completely.

Example 4 — the edge problem, measured

from scipy.spatial import cKDTree


def unit_xyz(lat, lon):
    la, lo = np.radians(lat), np.radians(lon)
    return np.column_stack([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)])


def nearest(df):
    d, j = cKDTree(unit_xyz(df.lat, df.lon)).query(unit_xyz(df.lat, df.lon), k=2)
    return j[:, 1], d[:, 1] * 6371008.8                  # chord ≈ arc at these distances


nn, nn_m = nearest(gb)
print(f"GB median nearest-neighbour distance {np.median(nn_m):.0f} m")
codes = {p: np.array([pgh.encode(a, b, precision=p) for a, b in zip(gb.lat, gb.lon)]) for p in range(4, 9)}
for p in range(4, 9):
    other = codes[p][nn] != codes[p]
    line = f"precision {p}: {other.mean():.1%} of nearest neighbours in a different cell"
    if p == 6:
        outside = np.mean([codes[6][j] not in neighbours_3x3(c) for c, j in zip(codes[6], nn)])
        line += f", {outside:.2%} outside the 3x3 block"
    print(line)

g9 = np.array([pgh.encode(a, b, precision=9) for a, b in zip(gb.lat, gb.lon)])
close = nn_m < 1000
shared = np.array([len(os.path.commonprefix([g9[i], g9[j]])) for i, j in zip(np.flatnonzero(close), nn[close])])
print(f"nearest pairs under 1 km: {close.sum():,}; sharing fewer than 5 characters {np.mean(shared < 5):.2%}, "
      f"fewer than 4 {np.mean(shared < 4):.2%}, none {int((shared == 0).sum())} "
      f"(median separation {np.median(nn_m[close][shared == 0]):.0f} m)")

for label, prec, radius in (("500 m at precision 5", 5, 0.5), ("500 m at precision 6", 6, 0.5),
                            ("1 km at precision 6", 6, 1.0)):
    home = block = total = 0
    for i in queries.index:
        near = np.flatnonzero(haversine_km(gb.lat[i], gb.lon[i], gb.lat.to_numpy(), gb.lon.to_numpy()) <= radius)
        cells = codes[prec][near]
        home += int((cells == codes[prec][i]).sum())
        block += int(np.isin(cells, neighbours_3x3(codes[prec][i])).sum())
        total += len(near)
    print(f"radius {label}: home cell finds {home / total:.1%}, 3x3 block {block / total:.1%}")

wn, wn_m = nearest(world)
w5 = np.array([pgh.encode(a, b, precision=5) for a, b in zip(world.lat, world.lon)])
print(f"global sample: median neighbour {np.median(wn_m):,.0f} m, {np.mean(w5[wn] != w5):.1%} cross at precision 5")

west, east = (51.4779, -0.000065), (51.4779, 0.000065)
print(f"Greenwich pair {haversine_km(*west, east[0], east[1]) * 1000:.1f} m apart: "
      f"{pgh.encode(*west, precision=9)} {pgh.encode(*east, precision=9)}")
right = pgh.get_adjacent("xbpbp", "right")
print(f"east of xbpbp ({pgh.decode('xbpbp')[1]:.2f}°): {right} at {pgh.decode(right)[1]:.2f}°")
try:
    pgh.get_adjacent(pgh.encode(89.99, 10.0, precision=5), "top")
except ValueError as e:
    print("ValueError:", e)
GB median nearest-neighbour distance 606 m
precision 4: 4.4% of nearest neighbours in a different cell
precision 5: 22.8% of nearest neighbours in a different cell
precision 6: 74.3% of nearest neighbours in a different cell, 21.76% outside the 3x3 block
precision 7: 95.7% of nearest neighbours in a different cell
precision 8: 99.2% of nearest neighbours in a different cell
nearest pairs under 1 km: 82,225; sharing fewer than 5 characters 15.45%, fewer than 4 2.79%, none 41 (median separation 477 m)
radius 500 m at precision 5: home cell finds 92.9%, 3x3 block 100.0%
radius 500 m at precision 6: home cell finds 64.7%, 3x3 block 100.0%
radius 1 km at precision 6: home cell finds 28.9%, 3x3 block 93.7%
global sample: median neighbour 1,746 m, 51.4% cross at precision 5
Greenwich pair 9.0 m apart: gcpuzgrbw u10hb5209
east of xbpbp (179.98°): 80000 at -179.98°
ValueError: No adjacent geohash to the top: it would lie beyond the north pole

Prefixes as cells, and neighbours that share nothing

Why a prefix is a cell

Each character appends five bisections. Keeping only the first n characters discards the later bisections and leaves the larger rectangle they subdivided. So truncation is exactly "the containing cell at a coarser precision", with no computation beyond slicing a string.

That makes geohash unusually convenient for systems that only understand strings: one sorted column serves every precision at once.

Why neighbours can share nothing

Sorting cells by their geohash traces a Z-order curve: it sweeps a quadrant completely before moving to the next. Cells that are adjacent across a quadrant boundary are far apart in that order.

The boundary between the first characters g and u is the prime meridian in northern mid-latitudes. Every British location just east of Greenwich starts with u; every one just west starts with g. The geometry is continuous; the string is not.

Why the edge problem gets worse as cells shrink

Whether a point's nearest neighbour is in the same cell depends on the ratio between neighbour distance and cell size. The British points have a median nearest-neighbour distance of 606 m.

A 39 km precision-4 cell holds almost every such pair, and only 4.4% cross a boundary. A 1.2 km precision-6 cell holds a minority of them: 74.3% cross one, and 21.76% of nearest neighbours were not even in the 3×3 block. In a sparser global sample of 2,000,000 points, with a median neighbour distance of 1,746 m, 51.4% already crossed at precision 5.

Why geohash cells are not equal areas

The bisection is in degrees, so a cell is a fixed fraction of longitude and latitude. On the ground, its width shrinks with the cosine of latitude while its height does not, so a precision-5 cell covers half the area in Oslo that it covers at the equator. Counts per geohash cell are therefore not densities, and a heat map built from them grows paler towards the poles for no reason in the data.

Share of true neighbours found by a geohash lookup, from the home cell and from its 3x3 block. share of true neighbours found by a geohash lookuphome cell onlywith the 3×3 blockp5 · 500 m!92.9%✓100.0%p6 · 500 m✕64.7%✓100.0%p6 · 1 km✕28.9%!93.7%The block is complete only while the radius fits inside one cell spatialworkflow.io
The block is nine lookups instead of one, and it is still hundreds of times faster than a distance scan.

The antimeridian, the poles and argument order

  • The antimeridian wraps. Measured, the eastern neighbour of xbpbp, at 179.98° east, is 80000, which decodes to −179.98°, so the 3×3 block crosses it correctly.
  • The poles do not. Asking for the northern neighbour of a cell at 89.99° north raised ValueError: No adjacent geohash to the top: it would lie beyond the north pole; catch it if your data reaches polar latitudes.
  • Pygeohash's argument order is encode(latitude, longitude), like H3 and unlike shapely.
  • Even precisions are rectangles twice as wide as tall at the equator; do not assume square cells in distance arithmetic.
  • Exact duplicates always share a cell. Deduplicate coordinates before measuring neighbour statistics, or the edge problem looks smaller than it is.
  • Precision beyond 9 or 10 exceeds the accuracy of most coordinates and only adds characters.
  • A geohash is not unique per cell area — the same string length means very different areas at different latitudes.
  • For hexagonal neighbourhoods or equal-ish areas, H3 or S2 are the better index; geohash's strength is that it is a plain string.

FAQ

What is a geohash?

A base-32 string that encodes a latitude–longitude cell by alternately halving longitude and latitude ranges. Each extra character narrows the cell by five bisections, so a prefix is always the containing cell.

Do points with the same geohash prefix lie close together?

Yes, they are in the same cell. The reverse is false: two points 9 m apart across the Greenwich meridian share no characters at all.

What is the geohash edge problem?

Nearby points on opposite sides of a cell boundary get different hashes. Measured on British locations, 22.8% of nearest neighbours fell in a different precision-5 cell and 74.3% in a different precision-6 cell.

How do I search for nearby points with geohash?

Look up the home cell and its eight neighbours, then filter candidates by exact distance. That found every neighbour within 500 m in a test of 2,000 searches, against 92.9% for the home cell alone.

How big is a geohash cell?

At precision 5, about 4.9 km square at the equator; at precision 6, 1.2 km by 0.6 km. Widths shrink with latitude, so both are narrower in northern Europe.

Should I use geohash or H3?

Geohash when you need a plain string key that any database can prefix-scan. H3 when you need near-equal areas or uniform neighbours for analysis.