Why Finding “Nearby” Is Harder Than You Think—And How Geohashes Solve It

Searching for the closest coffee shop sounds simple, but geospatial queries are tricky. Learn how geohashes make location-based searches efficient and scalable.

Suppose you are building a location-based service like Yelp or DoorDash. A customer wants to find restaurants near them that match certain preferences. Their device provides a GPS location (latitude, longitude), and your app needs to quickly retrieve relevant restaurants in their vicinity.

But how do you efficiently query locations in a database? Traditional indexing methods, such as B-Trees, are optimized for sorting and searching one-dimensional data (e.g., numbers or strings). However, geographic locations exist in two dimensions, making proximity-based searches challenging.

A naive approach—scanning all locations and filtering those within a given radius—is inefficient at scale. To handle this, modern applications use geospatial indexes, which structure location data in a way that enables efficient retrieval of nearby points.

One such indexing method is geohashing, which encodes latitude and longitude into a compact, searchable format that allows for efficient range queries and neighbor lookups. In this article, we’ll explore how geohashing works, how to encode and decode locations, and how it is used in real-world applications like ride-sharing and food delivery services.


Understanding Geohashes: A Space-Efficient Way to Index Locations #

Geohashing is a method of encoding geographic coordinates (latitude and longitude) into a compact, hierarchical string representation. While the underlying principles date back to 1966 with G.M. Morton’s work on Z-order curves 1, the term “geohash” was popularized in 2008 by Gustavo Niemeyer2. Niemeyer’s implementation introduced Base32 encoding, allowing for shorter, human-readable representations of locations while maintaining spatial hierarchy.

For example, the geohash "sr2yk3c" precisely identifies the Colosseum in Rome, while a shorter geohash like "sr2y" covers most of Rome, and "sr" represents a broader region, including much of the Italian Peninsula. As you can see, each additional character in a geohash increases its precision by further subdividing the geographic area.

How Geohashing Works #

Geohashing transforms two-dimensional latitude and longitude coordinates into a one-dimensional string. This is done by recursively dividing the Earth’s surface into smaller and smaller grid cells.

  1. The world is initially divided into 4 rows and 8 columns, forming 32 cells.
  2. Each cell is assigned a character from a Base32 encoding set (0123456789bcdefghjkmnpqrstuvwxyz i.e. all digits + all lowerchase characters except a, l, i, and o).
  3. Each of these 32 cells is further subdivided into another 32 smaller cells, continuing this pattern recursively.
  4. The resulting sequence of characters uniquely identifies a location, with longer geohashes representing finer precision.

This hierarchical structure enables geohashes to be used for efficient spatial indexing—shorter geohashes cover larger areas, while longer geohashes provide pinpoint accuracy.

Entire world split into 32 top level geohash cells.
Fig 1. Entire world split into 32 top level geohash cells.

https://geohash.softeng.co/

Advantages and Limitations of Geohashes #

Why Geohashing is Useful #

Challenges and Limitations #

The Role of Space-Filling Curves in Geohashing #

Geohashing relies on Z-order curves (Morton curves) to map 2D coordinates into a 1D space while preserving spatial locality. (sidenote: Space filling curves are continuous lines that can pass through every single point in a higher dimensional space like a square or a cube. You can watch this youtube video from 3Blue1Brown to learn more about them. ) like the Z-curve are useful because they:

Z-Curve Space-Filling Pattern (4x4 Grid)
Fig 2. Z-Curve Space-Filling Pattern (4x4 Grid)

Points are visited in Z-shaped patterns at multiple scales, creating a space-filling curve that preserves locality

However, Z-curves are not perfect. Some cases require additional checks to ensure proper neighbor retrieval, especially at cell boundaries.

With this foundation, let’s now dive into how geohashing works in practice by encoding latitude and longitude into a geohash string.


Encoding Latitude and Longitude into a Geohash #

In the previous section, we explored why geohashes are useful. Their compact Base32 representation reduces storage footprint, and their hierarchical nature enables efficient proximity searches—critical for applications like Yelp, DoorDash, and Uber.

Now, let’s dive into how geohashes are created by encoding latitude and longitude into a short Base32 string.

Step-by-Step Encoding Process #

Step 1: Define Latitude & Longitude Ranges #

Latitude and longitude define a point on the Earth’s surface:

Step 2: Convert to Binary by Recursively Halving the Range #

This step is similar to binary search—we iteratively halve the range and output a 0 or 1 based on whether the value is in the lower or upper half.

Encoding Latitude (41.878738)

We start with range [-90, 90] and refine it bit by bit.

StepMidpointNew RangeBit
10.0[0, 90]1
245.0[0, 45]0
322.5[22.5, 45]1
433.75[33.75, 45]1
539.375[39.375, 45]1
642.1875[39.375, 42.1875]0
740.78125[40.78125, 42.1875]1
841.484375[41.484375, 42.1875]1
941.8359375[41.8359375, 42.1875]1
1042.01171875[41.8359375, 42.01171875]0
1141.923828125[41.8359375, 41.923828125]0
1241.8798828125[41.8359375, 41.8798828125]0
1341.85791015625[41.85791015625, 41.8798828125]1
1441.868896484375[41.868896484375, 41.8798828125]1
1541.8743896484375[41.8743896484375, 41.8798828125]1
1641.87713623046875[41.87713623046875, 41.8798828125]1
1741.878509521484375[41.878509521484375, 41.8798828125]1
1841.87919616699219[41.878509521484375, 41.87919616699219]0
1941.87885284423828[41.878509521484375, 41.87885284423828]0
2041.87868118286133[41.87868118286133, 41.87885284423828]1

Final Latitude Binary: 10111000101001101110

Encoding Longitude (-87.6359612)

We start with range [-180, 180] and refine it bit by bit.

StepMidpointNew RangeBit
10.0[-180, 0]0
2-90.0[-90, 0]1
3-45.0[-90, -45]0
4-67.5[-90, -67.5]1
5-78.75[-90, -78.75]1
6-84.375[-90, -84.375]1
7-87.1875[-90, -87.1875]1
8-88.59375[-88.59375, -87.1875]0
9-87.890625[-88.59375, -87.1875]1
10-87.5390625[-87.890625, -87.1875]0
11-87.71484375[-87.890625, -87.71484375]1
12-87.802734375[-87.890625, -87.802734375]1
13-87.8466796875[-87.890625, -87.8466796875]1
14-87.86865234375[-87.890625, -87.86865234375]1
15-87.879638671875[-87.890625, -87.879638671875]1
16-87.8851318359375[-87.890625, -87.8851318359375]0
17-87.88238525390625[-87.8851318359375, -87.879638671875]0
18-87.88101196289062[-87.88238525390625, -87.879638671875]1
19-87.88032531738281[-87.88101196289062, -87.879638671875]1
20-87.8799819946289[-87.88032531738281, -87.879638671875]1

Final Longitude Binary: 01011110101111001111

Step 3: Interleave Bits #

We now interleave the bits, alternating lat/lon.

Lat Bits10111000101001101110
Lon Bits01011110101111001111

Interleaved:
11001101111000010110111001111011010111

Resulting 20-bit binary: 10011011110111101010

Step 4: Convert to Base32 #

Next, we split the 20-bit binary into 5-bit groups and convert each to a Base32 character:

Now, we convert every 5-bit chunk to Base32:

11001 10111 10000 10110 11100 11110 11010 111xx

Base32 Mapping:

Final Base32 Geohash: "dp3wjz"

Geohash of the Willis Tower in Chicago
Fig 3. Geohash of the Willis Tower in Chicago

Jorren Hendricks: https://geohash.jorren.nl/#dp3wjz

Python Implementation #

Now, let’s look at implemention of geohash encoding in Python. This is a slightly simplified and non-optimized version.

class Geohash:
    BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"

    def __init__(self, precision=6):
        self.precision = precision  # Number of geohash characters

    def _latlon_bin(self, value: float, lo: float, hi: float, bit_length: int) -> str:
        """Convert latitude or longitude to a binary representation."""
        res = []
        for _ in range(bit_length):
            mid = (lo + hi) / 2
            if value > mid:
                lo = mid
                res.append('1')
            else:
                hi = mid
                res.append('0')
        return ''.join(res)

    def encode(self, lat: float, lon: float) -> str:
        """Encode a latitude and longitude into a geohash."""
        bit_length = self.precision * 5  # 5 bits per Base32 character
        
        # Get binary representation of latitude and longitude
        lat_bin = self._latlon_bin(lat, -90, 90, bit_length // 2)
        lon_bin = self._latlon_bin(lon, -180, 180, bit_length // 2)

        # Interleave latitude and longitude bits
        interleaved_bin = ''.join(
            lon_bin[i] + lat_bin[i] for i in range(len(lat_bin))
        )

        # Convert binary to Base32
        geohash = ''.join(
            self.BASE32[int(interleaved_bin[i:i+5], 2)]
            for i in range(0, len(interleaved_bin), 5)
        )

        return geohash

# Example Usage
geo = Geohash(precision=6)
encoded = geo.encode(41.878738, -87.6359612)  # Willis Tower
print(f"Encoded Geohash: {encoded}")

Summary of the Encoding Process #

  1. Recursively divide latitude and longitude into binary by checking if they fall above or below the midpoint.
  2. Interleave the bits, alternating longitude and latitude.
  3. Convert the binary sequence to Base32 to form the final geohash.

This process results in a short, hierarchical geohash that efficiently represents a geographic location.

With encoding covered, we now turn our attention to decoding geohashes back into latitude and longitude in the next section.


Decoding a Geohash #

In the encoding section, we transformed a latitude and longitude pair into a compact Base32 string. Now, we’ll reverse the process—converting a geohash back into latitude and longitude. Once you understand encoding, decoding becomes straightforward as it essentially follows the same steps in reverse.

Step-by-Step Decoding Process #

Step 1: Convert Base32 to Binary #

Step 2: Separate the Interleaved Bits #

Step 3: Reverse the Binary Search Process #

Step 4: Compute the Final Coordinates #

Python Implementation #

We’ll implement a function that:

  1. Converts a geohash to its binary representation.
  2. Separates the interleaved bits for latitude and longitude.
  3. Performs a reverse binary search to refine coordinate ranges.
  4. Returns the midpoint of the final latitude and longitude ranges.

Example usage with "rffv" should return coordinates close to (41.878738, -87.6359612).

Summary of the Decoding Process #

Now that we can encode and decode geohashes, the next step is understanding how to find neighboring geohashes efficiently. This is crucial for location-based services like ride-sharing, food delivery, and geospatial databases.


Finding Nearest Neighbors in Geohash #

Location-based services frequently compute the nearest neighbors of a geohash to find nearby places, users, or points of interest. Understanding how this works under the hood is crucial for applications like geospatial databases, ride-sharing, and real-time location tracking.

Step-by-Step Process for Finding Neighbors #

Step 1: Decode to Find the Center of the Current Cell #

Step 2: Compute the Cell Size Based on Geohash Precision #

Step 3: Compute the Coordinates for the 8 Neighboring Cells #

Step 4: Encode the New Locations Back to Geohashes

Python Implementation #

def _get_cell_size(self, precision: int) -> tuple[float, float]:
    """Calculate the approximate size of a geohash cell for a given precision.

    Args:
        precision (int): precision/length of geohash

    Returns:
        (latitude_error, longitude_error)
    """
    lat_bits = precision * 5 // 2
    lon_bits = precision * 5 - lat_bits

    lat_err = 180.0 / (1 << lat_bits)
    lon_err = 360.0 / (1 << lon_bits)

    return lat_err, lon_err

def get_neighbors(self, geohash: str) -> dict[str, str]:
    """
    Compute the 8 neighboring geohashes (N, S, E, W, NE, NW, SE, SW).
    """
    center_lat, center_lon = self.decode(geohash)
    lat_height, lon_width = self._get_cell_size(len(geohash))
    directions = {
        "n": (lat_height, 0),
        "s": (-lat_height, 0),
        "e": (0, lon_width),
        "w": (0, -lon_width),
        "ne": (lat_height, lon_width),
        "se": (-lat_height, lon_width),
        "nw": (lat_height, -lon_width),
        "sw": (-lat_height, -lon_width),
    }
    neighbors = {}
    for direction, (dlat, dlon) in directions.items():
        nlat, nlon = center_lat + dlat, center_lon + dlon

        # handle latitude bounds
        nlat = max(min(nlat, 90), -90)

        # handle longitude wrapping
        if nlon > 180:
            nlon -= 360
        elif nlon < -180:
            nlon += 360

        neighbors[direction] = self.encode(nlat, nlon)
    return neighbors

Summary of finding the neighbors #

Now that we can compute neighbors, let’s explore how this is used in geospatial databases.


Geohashes in Practice #

In the earlier sections, we explored encoding, decoding, and finding the nearest geohash neighbors of a given location. However, simply identifying neighboring geohash cells does not directly return the closest objects we seek. To achieve that, we need to query a geospatial database for all objects matching a geohash prefix and filter results based on actual proximity.

A common approach is to first check whether the current geohash cell contains enough points. The optimal geohash precision for such queries is typically 6 to 7 characters, balancing accuracy and coverage. If there aren’t enough points within this initial cell, we must expand the search space. This is usually done using a breadth-first search (BFS), exploring neighboring cells while ensuring each cell is visited only once. The search terminates as soon as a sufficient number of points are found—either stopping immediately if we only care about retrieving a minimum number of results or continuing until a full layer is processed for precise ordering.

Building such a system from scratch would require implementing efficient spatial indexing and search strategies. Fortunately, several databases natively support geohashes and provide optimized ways to store, index, and retrieve geospatial data. Two widely used systems that integrate geohashes are Redis and Elasticsearch.

Using Redis for Geospatial Queries #

Redis provides built-in support for geospatial indexing via its GEOADD and GEOSEARCH commands, making it easy to store and retrieve locations efficiently. Below, we demonstrate how a food delivery service could use Redis to manage restaurant locations.

Storing Locations in Redis #

# GEOADD restaurants -73.9857 40.7484 "restaurant:1001"  # CLI equivalent
r.geoadd("restaurants", (lon, lat, restaurant_id))

Each entry consists of longitude, latitude, and a unique identifier, allowing us to store millions of locations efficiently.

Querying Nearby Locations #

To find the closest restaurants for a given user location, we can use GEOSEARCH, which efficiently retrieves nearby locations within a specified radius.

# Retrieving restaurants within 3 km of a customer
customer_id, customer_lat, customer_lon = customers[0]

res = r.geosearch(
    "restaurants",
    longitude=customer_lon,
    latitude=customer_lat,
    radius=3,
    unit="km",
    withdist=True,  # Include distances in the result
    sort="ASC",  # Sort results by proximity
)

print(f"Found {len(res)} restaurants near {customer_id}")
for rid, dist in res:
    metadata = r.hgetall(f"metadata:{rid}")
    print(f" - {metadata['name']} ({metadata['cuisine']}) - {dist:.2f} km away")

Optimized Querying: If a latitude-longitude pair is already stored in Redis (e.g., user locations), we can use the member argument to directly perform the GEOSEARCH query without manually providing coordinates.

Pagination with Redis #

In real-world applications, sending an unbounded list of search results to a client is inefficient. Instead, pagination can be implemented using Redis Sorted Sets (ZSET). This allows storing query results temporarily, sorted by distance, so that subsequent pages can be efficiently fetched.

def paginate_geosearch(
    key: str,
    lon: float,
    lat: float,
    radius: float,
    unit="km",
    page_size=5,
    page_num=1,
    session_id=None,
):
    if session_id is None:
        session_id = str(uuid.uuid4())

    temp_key = f"temp:{session_id}:key"

    if page_num == 1 and (not r.exists(temp_key) or r.ttl(temp_key) < 60):
        results = r.geosearch(
            "restaurants",
            longitude=lon,
            latitude=lat,
            radius=radius,
            unit=unit,
            withdist=True,
            count=MAX_INITIAL_RESULTS,
            sort="ASC",
        )
        pipe = r.pipeline()
        for member_id, distance in results:
            pipe.zadd(temp_key, {member_id: distance})

        pipe.expire(temp_key, 600)  # Set expiration for 10 mins
        pipe.execute()
    start = (page_num - 1) * page_size
    end = start + page_size - 1
    results = r.zrange(temp_key, start, end, withscores=True)
    return session_id, results

This method allows efficient paginated access to search results while ensuring that temporary data is cleaned up automatically using Redis’ Time-To-Live (TTL) feature.

Using Geohashes in Elasticsearch #

Elasticsearch provides powerful geospatial search capabilities via its geo_point data type. Unlike Redis, which primarily operates on simple radius-based lookups, Elasticsearch allows:

Example: Storing & Querying Geohashes in Elasticsearch #

To store a location in Elasticsearch, we can define a mapping with a geo_point field:

{
  "mappings": {
    "properties": {
      "location": { "type": "geo_point" }
    }
  }
}

We can then index a restaurant with a geohash:

{
  "name": "Pasta Place",
  "cuisine": "Italian",
  "location": {
    "lat": 40.7484,
    "lon": -73.9857
  }
}

To find restaurants within 3 km of a user’s location:

{
  "query": {
    "geo_distance": {
      "distance": "3km",
      "location": {
        "lat": 40.7484,
        "lon": -73.9857
      }
    }
  }
}

This allows scalable, full-text search with geospatial filters, making Elasticsearch ideal for applications that require complex queries, ranking, and filtering.

Apache Sedona: Geospatial Processing at Scale #

Apache Sedona is an open-source geospatial analytics library built on Apache Spark. Unlike Redis and Elasticsearch, which focus on real-time querying, Sedona is designed for large-scale geospatial data processing.

Who Uses Apache Sedona? #

Common Use Cases #

Apache Sedona supports geohashes for spatial partitioning and efficient indexing, making it valuable for big-data-driven geospatial applications.

Conclusion #

We explored how Redis and Elasticsearch provide built-in support for geospatial indexing using geohashes, each with different strengths:

Next, we’ll look at what are some of the alternatives to geohashes.

Here’s a revised version of the alternatives section with clear, consistent details and a balanced view of advantages and disadvantages:


Geohash Alternatives in Geospatial Indexing #

While geohashes offer a compact and hierarchical way to index spatial data, they are not without limitations. Other spatial indexing methods have emerged, each with its own strengths and trade-offs. Below, we review three popular alternatives: R-Trees, S2Geometry, and H3.

R-Tree #

An R-Tree3 is a balanced tree structure designed to efficiently store spatial data by grouping objects using rectangular Minimum Bounding Regions (MBRs).

R-Trees are widely used in spatial databases and systems such as PostGIS, MySQL, DuckDB and Elasticsearch.

S2Geometry #

An illustration of the S2 curve after 5 levels of subdivision
Fig 4. An illustration of the S2 curve after 5 levels of subdivision

S2Geometry: https://s2geometry.io

S2Geometry, developed by Google, uses a Hilbert space-filling curve to map the sphere into cells, providing a hierarchical spatial index.

S2Geometry is employed in systems like Google Spanner, CockroachDB, and DynamoDB’s geo library.

H3 #

How Uber's H3 hierarchical spatial index allow users to partition the world into hexagons
Fig 5. How Uber’s H3 hierarchical spatial index allow users to partition the world into hexagons

Uber engineering blog https://www.uber.com/blog/h3/

H3, developed by Uber, is a hierarchical spatial index that uses hexagonal cells (with a few pentagons to cover the sphere) to represent geographic areas.

H3 is popular for applications like ride pricing optimization, real-time geospatial analytics, and visualization.

Each of these alternatives offers a unique balance of performance, precision, and complexity. Choosing the right one depends on your specific application requirements, such as the nature of your queries, data update frequency, and the acceptable level of spatial distortion.


Closing Notes #

In this article, we explored geospatial indexing with a focus on geohashes—covering their encoding, decoding, neighbor lookups, and practical applications. We also discussed how databases like Redis and Elasticsearch efficiently leverage geohashes for spatial queries. Finally, we examined alternative geospatial indexing methods such as R-Trees, S2Geometry, and H3, each offering unique trade-offs in precision, query performance, and complexity.

Geohashes provide a compact and hierarchical way to index geospatial data, making them well-suited for many location-based applications. However, they are not a one-size-fits-all solution—depending on the use case, alternative indexing structures may offer better performance for nearest-neighbor searches, dynamic updates, or handling spherical distortions.

Understanding how these indexing methods work under the hood is crucial when designing geospatial systems that need to balance efficiency, accuracy, and scalability. Whether building a location-based search, a navigation system, or a geospatial analytics platform, choosing the right indexing approach will significantly impact performance and user experience.

With geospatial data playing a growing role in applications from ride-sharing to logistics and urban planning, mastering geohashes and their alternatives equips us with the tools to build more efficient and scalable systems.

You can find the full implementation of the geohash and my redis experiments in my github repo