In short: a geohash turns a latitude/longitude pair into a short base-32 string where a shared prefix means physical proximity — so SELECT ... WHERE geohash LIKE 'dr5ru%' finds nearby points using an ordinary B-tree, no PostGIS required. It is the portable, database-agnostic companion to the designing spatial indexes for geocoded data guide, ideal for coarse bucketing, sharding, and deduplication keys.
Why geohash for proximity
A geohash recursively bisects the world into a grid: each additional character narrows the cell roughly 8-fold in area. Because the encoding interleaves longitude and latitude bits, two points whose geohashes share a long common prefix are close together. That property lets you do proximity search with string operations alone — prefix matching, GROUP BY on a truncated hash, or a plain B-tree range scan — which travels anywhere a text column does: Postgres, Redis sorted sets, Parquet, or a flat file.
The catch is that the converse is not guaranteed: two nearby points can have different prefixes if they sit on opposite sides of a cell boundary. Handle that with neighbour cells (below) and geohash becomes a reliable coarse filter.
Precision vs cell size
| Geohash length | Cell width (approx at equator) | Cell height | Typical use |
|---|---|---|---|
| 4 | ~39 km | ~20 km | Metro-area bucketing, sharding |
| 5 | ~4.9 km | ~4.9 km | Neighbourhood clustering |
| 6 | ~1.2 km | ~0.6 km | Coarse dedup / delivery zones |
| 7 | ~153 m | ~153 m | Street-block proximity |
| 8 | ~38 m | ~19 m | Rooftop-level dedup key |
| 9 | ~4.8 m | ~4.8 m | Sub-building precision |
Cell dimensions shrink toward the poles because meridians converge, so treat these as equatorial upper bounds. For address deduplication, length 7–8 balances collapsing genuine duplicates against splitting truly distinct neighbours.
Pure-Python encoder
The encoder below has no third-party dependency. The base-32 alphabet and its lookup are built once at module level so repeated calls stay cheap, and full type hints keep it drop-in for a typed codebase.
from __future__ import annotations
from typing import Final
# Geohash base-32 alphabet (excludes a, i, l, o to avoid ambiguity).
_BASE32: Final[str] = "0123456789bcdefghjkmnpqrstuvwxyz"
_BITS: Final[tuple[int, ...]] = (16, 8, 4, 2, 1)
def encode_geohash(lat: float, lon: float, precision: int = 8) -> str:
"""Encode a latitude/longitude pair to a geohash string.
Args:
lat: Latitude in degrees, -90 to 90.
lon: Longitude in degrees, -180 to 180.
precision: Number of geohash characters (1-12). Higher is finer.
Returns:
A base-32 geohash string of the requested length.
Raises:
ValueError: If coordinates fall outside valid ranges.
"""
if not -90.0 <= lat <= 90.0:
raise ValueError(f"latitude out of range: {lat}")
if not -180.0 <= lon <= 180.0:
raise ValueError(f"longitude out of range: {lon}")
lat_range = [-90.0, 90.0]
lon_range = [-180.0, 180.0]
geohash: list[str] = []
bit = 0
ch = 0
even = True # start by bisecting longitude
while len(geohash) < precision:
if even:
mid = (lon_range[0] + lon_range[1]) / 2.0
if lon >= mid:
ch |= _BITS[bit]
lon_range[0] = mid
else:
lon_range[1] = mid
else:
mid = (lat_range[0] + lat_range[1]) / 2.0
if lat >= mid:
ch |= _BITS[bit]
lat_range[0] = mid
else:
lat_range[1] = mid
even = not even
if bit < 4:
bit += 1
else:
geohash.append(_BASE32[ch])
bit = 0
ch = 0
return "".join(geohash)
Vectorized pandas column
For a DataFrame of geocoded points, apply the encoder across rows to materialize a geohash column you can then group, join, or index. For very large frames, precompute once and store the column rather than recomputing per query.
from __future__ import annotations
import pandas as pd
def add_geohash_column(
df: pd.DataFrame,
lat_col: str = "lat",
lon_col: str = "lon",
precision: int = 8,
out_col: str = "geohash",
) -> pd.DataFrame:
"""Add a geohash column derived from latitude/longitude columns.
Args:
df: Input frame containing coordinate columns.
lat_col: Name of the latitude column.
lon_col: Name of the longitude column.
precision: Geohash length.
out_col: Name of the geohash column to create.
Returns:
A copy of df with the geohash column appended.
"""
out = df.copy()
out[out_col] = [
encode_geohash(lat, lon, precision)
for lat, lon in zip(out[lat_col], out[lon_col])
]
# Coarser bucket keys derived by truncation — no re-encoding needed.
out["geohash6"] = out[out_col].str.slice(0, 6)
out["geohash5"] = out[out_col].str.slice(0, 5)
return out
Truncating a length-8 geohash to its first 6 characters yields the length-6 cell for free — coarser buckets are always prefixes of finer ones. That makes a single stored column serve multiple zoom levels.
Geohash prefixes as dedup and cluster keys
Because a prefix is a spatial bucket, grouping records by a truncated geohash gives you cheap clustering: every row sharing geohash6 sits in the same ~1 km cell. This is a fast pre-filter for the more expensive string comparison in the fuzzy canonical key deduplication workflow — you only run costly fuzzy matching within a geohash bucket rather than across the whole dataset, turning an O(n²) comparison into a per-bucket problem.
def bucket_by_geohash(df: pd.DataFrame, prefix_len: int = 6) -> "pd.core.groupby.DataFrameGroupBy":
"""Group rows into coarse spatial buckets by geohash prefix.
Each group is a candidate set for within-cell fuzzy deduplication.
"""
return df.assign(bucket=df["geohash"].str.slice(0, prefix_len)).groupby("bucket")
Edge cases
Cells straddling a boundary
Two addresses 20 metres apart can land in adjacent cells with different prefixes if a cell edge runs between them. A prefix-only search misses the neighbour. The fix is to also search the eight surrounding cells. Compute neighbours (a well-known geohash operation) and query geohash LIKE ANY (center + 8 neighbours), or store points at two offset precisions. For exact nearest-neighbour results, fall back to a PostGIS <-> KNN query from the spatial index guide — geohash is a coarse filter, not a precise ranker.
Precision loss and false merges
At length 6 (~1 km) two genuinely different buildings on the same block share a cell and may be wrongly merged if you use the prefix alone as a dedup key. Always confirm a candidate merge with an address-string comparison; never treat a shared geohash as proof of identity.
Antimeridian and poles
The 180° meridian and the poles are cell edges where neighbour computation wraps or collapses. Points just east and west of the antimeridian never share a prefix despite being metres apart. For datasets that cross the seam, treat the antimeridian explicitly or project to a local grid — the same caution the spatial index guide raises for bounding-box queries.
Integration note
Geohash and PostGIS are complementary, not competing. Store a length-8 geohash column alongside your geometry(Point, 4326) and you get two tools: a portable prefix key for bucketing, sharding, and cache keys that travels into Redis or Parquet, plus a true spatial index for exact radius and KNN work. Sorting a table by geohash before a bulk load is also precisely what makes a BRIN index viable, as shown in choosing GiST vs BRIN in PostGIS.
Related
- Designing spatial indexes for geocoded data — the parent guide on PostGIS geometry types, GiST/BRIN indexes, and KNN queries.
- Choosing GiST vs BRIN in PostGIS — why a geohash sort key unlocks a tiny, fast BRIN index for archives.
- Deduplicating addresses by fuzzy canonical key — use geohash prefixes as a coarse pre-filter before expensive fuzzy comparison.