Geo Search System Low-Level Design

Geo Search System — Low-Level Design

A geo search system finds nearby entities (restaurants, drivers, listings) given a user’s location. It must handle spatial indexing, radius queries, bounding-box queries, and ranking by distance. This design is asked at Uber, Lyft, Airbnb, and Yelp.

Core Data Model

-- PostgreSQL with PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;

Location
  id              BIGSERIAL PK
  entity_id       BIGINT NOT NULL     -- restaurant, driver, listing ID
  entity_type     TEXT NOT NULL       -- 'restaurant', 'listing'
  coordinates     GEOMETRY(Point, 4326) NOT NULL  -- SRID 4326 = WGS84 lat/lng
  city            TEXT
  updated_at      TIMESTAMPTZ

-- Spatial index (required for fast geo queries)
CREATE INDEX idx_location_geo ON Location USING GIST(coordinates);

-- Inserting a point
INSERT INTO Location (entity_id, entity_type, coordinates)
VALUES (42, 'restaurant', ST_SetSRID(ST_MakePoint(-73.9857, 40.7484), 4326));
-- ST_MakePoint(longitude, latitude) — note: longitude first

Radius Query (Find Nearby)

def find_nearby(lat, lng, radius_meters, entity_type, limit=50):
    return db.execute("""
        SELECT
            l.entity_id,
            ST_Distance(
                l.coordinates::geography,
                ST_SetSRID(ST_MakePoint(%(lng)s, %(lat)s), 4326)::geography
            ) AS distance_meters
        FROM Location l
        WHERE l.entity_type = %(type)s
          AND ST_DWithin(
              l.coordinates::geography,
              ST_SetSRID(ST_MakePoint(%(lng)s, %(lat)s), 4326)::geography,
              %(radius)s
          )
        ORDER BY distance_meters ASC
        LIMIT %(limit)s
    """, {'lat': lat, 'lng': lng, 'radius': radius_meters,
          'type': entity_type, 'limit': limit})

# ST_DWithin with ::geography uses great-circle distance (accurate for real distances)
# The GIST index makes ST_DWithin an index scan, not a table scan

Geohash-Based Approach (Without PostGIS)

# Geohash: encode lat/lng as a string where shared prefix = proximity
# Precision: 5 chars ≈ 2.4km × 5km cell, 6 chars ≈ 600m × 1.2km cell

import geohash2

def encode_location(lat, lng, precision=6):
    return geohash2.encode(lat, lng, precision)

def find_nearby_geohash(lat, lng, radius_km):
    center_hash = geohash2.encode(lat, lng, precision=6)
    # Get all neighboring cells to avoid edge-of-cell misses
    neighbors = geohash2.neighbors(center_hash) + [center_hash]

    results = db.execute("""
        SELECT entity_id, lat, lng FROM Location
        WHERE geohash6 = ANY(%(hashes)s)
    """, {'hashes': neighbors})

    # Filter precisely by actual distance
    user_point = (lat, lng)
    nearby = [
        r for r in results
        if haversine(user_point, (r.lat, r.lng)) <= radius_km
    ]
    return sorted(nearby, key=lambda r: haversine(user_point, (r.lat, r.lng)))

Updating Driver/User Locations (High Write Rate)

# Drivers update location every 5 seconds → 100K drivers = 20K writes/second
# PostgreSQL cannot sustain this; use Redis GEOADD instead

def update_driver_location(driver_id, lat, lng):
    # GEOADD stores as a sorted set with geohash score
    redis.geoadd('drivers:active', lng, lat, driver_id)
    # Set TTL per driver: expire if not updated for 30 seconds
    redis.expire(f'driver:active:{driver_id}', 30)

def find_nearby_drivers(lat, lng, radius_meters):
    # GEORADIUS: O(N+log M) where N=results, M=total entries
    results = redis.georadius(
        'drivers:active', lng, lat,
        radius_meters / 1000,  # km
        unit='km',
        withcoord=True,
        withdist=True,
        sort='ASC',
        count=10
    )
    return [{'driver_id': r[0], 'distance_km': r[1], 'coords': r[2]}
            for r in results]

Bounding Box Query (Map Viewport)

def find_in_viewport(min_lat, min_lng, max_lat, max_lng, entity_type):
    """Return all entities visible in a map viewport."""
    return db.execute("""
        SELECT l.entity_id,
               ST_Y(l.coordinates) AS lat,
               ST_X(l.coordinates) AS lng
        FROM Location l
        WHERE l.entity_type = %(type)s
          AND l.coordinates && ST_MakeEnvelope(
              %(min_lng)s, %(min_lat)s,
              %(max_lng)s, %(max_lat)s,
              4326
          )
        LIMIT 200
    """, {'type': entity_type, 'min_lat': min_lat, 'min_lng': min_lng,
          'max_lat': max_lat, 'max_lng': max_lng})
# && operator = bounding box overlap — uses GIST index

Key Interview Points

  • PostGIS for static entities, Redis for moving entities: Restaurants don’t move — PostGIS with a GIST index is ideal. Drivers update every 5 seconds — Redis GEOADD/GEORADIUS handles the write rate that would crush Postgres.
  • Geohash neighbor lookup prevents edge misses: A user at the edge of a geohash cell may be closer to entities in the neighboring cell than to entities in their own cell. Always query the 8 neighboring cells plus the center.
  • geography vs geometry in PostGIS: geometry uses flat-earth math (fast, inaccurate for large distances). geography uses spherical math (accurate great-circle distances, ~20% slower). Use geography for ST_DWithin and ST_Distance when accuracy matters.
  • Limit result set before sorting: ST_DWithin with a GIST index returns the candidate set efficiently. Compute exact distance only on the candidates, not the whole table.

Geo search and driver location tracking design is discussed in Uber system design interview questions.

Geo search and nearby driver system design is covered in Lyft system design interview preparation.

Geo search and listing proximity design is discussed in Airbnb system design interview guide.

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

Scroll to Top