Geohash Encoding for Address Proximity Search in Python

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.