SYSTEM DESIGN · DESIGN PROBLEMS · LOCATION · MEDIUM · 19 OF 41

Design Yelp: A Proximity Service with Geohashes, Quadtrees and Ranked Results

How to design a proximity service like Yelp: requirements, scale math showing the whole geo index fits in memory, the nearby-search and business APIs, why a plain latitude and longitude index fails, geohash cells and neighbour lookups, quadtrees and S2 or H3 as alternatives, combining distance with filters and ranking, rating aggregation, caching, and the trade-offs.

Reported atAirbnb

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 17

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Requirements. A user searches for businesses near a location, within a radius like five hundred metres or five kilometres, and filters by category, price and open now. Results can be sorted by distance or rating. They view details, and write reviews with a star rating. Business owners can edit their listing. Booking, ads and photo handling are out of scope.

Qualities. Search should come back in under a hundred milliseconds at p ninety-nine. It's very read heavy, and business data changes slowly: a new restaurant appearing a few minutes late is fine, so we can rebuild indexes in the background. A user should see their own review immediately. And for privacy, we don't keep a history of where users searched from.

Functional requirements
  • in scope:
  • • search businesses near a
  • location, within a radius
  • • filter: category, open now,
  • price; sort by rating or
  • distance
  • • view business details
  • • write a review, star rating
  • • owners edit their listing
  • out: booking, ads, photos
Non-functional requirements
  • search p99 under 100 ms
  • very read heavy
  • new or edited business can
  • take minutes to appear
  • new review visible to its
  • author at once
  • available, global users
  • privacy: do not store user
  • location history

Ask scale

Say two hundred million businesses worldwide and a hundred million daily users doing five searches each: five thousand queries a second, fifteen thousand at peak. Business details are about a kilobyte each, two hundred gigabytes in total. But the geo index needs only an ID and a location code per business, sixteen bytes, which is about three gigabytes. The whole index fits in the memory of a single server, so we can replicate it rather than shard it.

The numbers
businesses            200M
DAU                   100M
searches/user/day     5
  500M / 100,000 s  = 5K qps
  peak x3           = 15K qps

business row ~1 KB
  200M x 1 KB       = 200 GB
geo entry: id 8 B + hash 8 B
  200M x 16 B       = 3.2 GB
-> geo index fits in RAM

Define API

The API. The nearby search takes a latitude and longitude, a radius in metres, optional filters and a sort, and returns a page of compact results: ID, name, distance, rating and price level. Full details are a separate call, because most results are never tapped. Reviews are a POST on the business, and owners update their listing with a PUT.

Core API
GET /v1/search/nearby
  ?lat=28.46&lng=77.03
  &radius=2000&cat=cafe
  &open_now=1&sort=best
  &cursor=..&limit=20
  -> [{ id, name, distanceM,
       rating, priceLevel }]
GET  /v1/businesses/{id}
POST /v1/businesses/{id}/reviews
PUT  /v1/businesses/{id}

Data model

Data model. Businesses and reviews are classic relational data with modest volume, so a SQL database with read replicas works well. The business row stores its coordinates, category, hours, and a denormalised average rating and review count. A user gets one review per business, enforced by a unique key. The geo index is derived: a map from a location cell to the business IDs in it, rebuilt from database changes.

Records
business (id PK, name, lat, lng,
          category, price, hours,
          avgRating, reviewCount)
review   (id, businessId, userId,
          stars, text, ts)
  unique (businessId, userId)

geo index (derived):
  geohash6 -> [businessId..]
rebuilt from DB changes

High-level design

The high-level design. A stateless search service holds the geo index in memory or reads it from a nearby index replica. For a query, it finds the location cells covering the search circle, gets the business IDs in them, filters and ranks, then hydrates the top twenty from a cache backed by read replicas. Business edits go through a separate business service to the primary database.

Deep dive: why not SQL

First, why not just index latitude and longitude? A query with a range on latitude and a range on longitude can use an index for only one of them efficiently. An index on latitude returns a thin band that circles the whole planet, and then you filter longitude row by row. The fix is to turn two-dimensional positions into one-dimensional keys that keep nearby places close together. That's exactly what a geohash does.

The naive query
SELECT id FROM business
 WHERE lat BETWEEN :a AND :b
   AND lng BETWEEN :c AND :d

index on lat: returns a thin
  band around the whole world
index on lng: same problem
composite (lat, lng): range on
  lat, then scan every lng

need: map 2D to 1D keys

Deep dive: geohash

A geohash repeatedly splits the world in half, alternating longitude and latitude, recording a bit for which half you're in. Every five bits become one base-thirty-two character. A longer hash means a smaller cell: five characters is about five kilometres, six is about a kilometre by six hundred metres, seven is about a hundred fifty metres. Places that share a prefix are in the same cell, so 'all businesses in this cell' becomes a simple prefix or key lookup.

To search a radius, choose the precision from the radius, then look up the user's cell and its eight neighbours. The neighbours matter: if you're near the edge of a cell, the closest café might be just across the border, in a cell with a completely different hash. Fetch the IDs from those nine cells, compute the exact great-circle distance, and drop anything outside the radius. If too few results come back, step up to a larger cell size.

Geohash
split the world in half by
  longitude, then latitude,
  again and again: one bit each
every 5 bits -> 1 base32 char

length  cell size (approx)
  4     39 km x 20 km
  5     4.9 km x 4.9 km
  6     1.2 km x 0.6 km
  7     153 m x 153 m
shared prefix = same area
Searching a radius
  • 1 pick precision from radius:
  • 2 km -> length 5 (4.9 km)
  • 2 compute the user's cell
  • + its 8 neighbours
  • (edges: the closest place
  • may be across a border)
  • 3 fetch IDs for those 9 cells
  • 4 exact distance (haversine),
  • drop those outside radius
  • 5 too few? go one level up

Deep dive: quadtree

The main alternative is a quadtree. Start with the whole map, and whenever a cell holds more than about a hundred places, split it into four. Manhattan ends up with tiny cells, the desert with huge ones, so every leaf holds a similar number of results. It lives in memory and is rebuilt at startup. Libraries like S2, which uses cells on a cube, or H3, which uses hexagons, give more uniform cells. In an interview, geohash is the easiest to explain and store; mention quadtrees for uneven density.

Quadtree and friends
  • quadtree: split a cell into
  • 4 when it has over ~100
  • places; dense city = deep
  • built in memory at startup
  • query: walk down to leaves
  • that touch the circle
  • S2 (cube cells), H3 (hexagons)
  • even cells, used by maps
  • geohash: simplest, stores in
  • any key-value or SQL index

Deep dive: ranking

Location narrows things to a few hundred candidates. Now filter by category, price and open now, which needs the business's hours and timezone. Then score. Raw average stars would let one five-star review beat four point six from two thousand, so use a Bayesian average that pulls small samples toward the overall mean, add a bonus for volume and subtract a distance penalty. Elasticsearch can do geo, text and filters in one query, a fine alternative to building it yourself.

Filter and rank
  • candidates: ~300 in 9 cells
  • filter: category, price,
  • open now (hours + timezone)
  • score, e.g.:
  • rating quality (Bayesian
  • average, not raw stars)
  • + review count, log scale
  • - distance penalty
  • top 20, cursor for more
  • or: Elasticsearch geo query

Deep dive: reviews

Reviews. Insert the review row, with the unique key preventing duplicates. Then an event goes to an aggregator that keeps a running sum and count per business, so the average is just sum divided by count, with no rescanning. An edit subtracts the old stars and adds the new. The average in search results can lag by a minute. The author, though, reads their own review from the primary, so it appears immediately. Spam filters run before a review counts.

Ratings
  • insert review row (unique
  • per user + business)
  • event -> aggregator
  • keep sum + count, so avg
  • = sum / count, no rescans
  • edits: subtract old, add new
  • author sees own review at
  • once (read from primary)
  • spam: filter before it counts

Trade-offs

Scaling and failures. The index is only a few gigabytes, so replicate it to every search node rather than sharding, and scale reads by adding nodes. Keep it fresh from the database's change stream; minutes of lag is acceptable. Busy areas like a city centre are hot cells, so cache query results by cell and filters for a minute. If a search node dies, the load balancer routes around it. Monitor p ninety-nine latency and the share of searches that return nothing.

Trade-offs. Geohash is simple and works in any database, but cells have fixed sizes and you must handle borders. Quadtrees adapt to density but are in-memory structures you rebuild. Replicating the full index costs memory on every node but avoids scatter-gather queries. Async rating updates trade a minute of lag for cheap writes. At ten times the scale, or if you add many attributes to the index, shard it by region, since a search rarely crosses regions.

Scaling and failures
  • geo index 3 GB: replicate to
  • every search node, no shard
  • refresh: DB change stream
  • -> index updates, minutes
  • hot cells (Times Square):
  • cache query results by
  • cell + filters, ~1 min
  • replica lag: fine for search
  • search node dies: LB skips it
  • watch: p99, empty results %
Trade-offs to name
  • geohash: simple, but fixed
  • cell sizes and edge cases
  • quadtree: adapts to density,
  • but lives in memory
  • replicate vs shard: memory
  • per node for simplicity
  • async ratings: lag for cheap
  • writes
  • 10x: shard index by region

Say it like this

The summary. It's a read-heavy service, five to fifteen thousand queries a second, and the geo index is only a few gigabytes, so keep it in memory and replicate it. Encode locations as geohashes, pick the precision from the radius, and look up the user's cell plus its eight neighbours. Filter by exact distance, then rank with a Bayesian rating, review volume and a distance penalty. Hydrate from cache. Mention quadtrees for uneven density.

The 60-second answer
  • 5K-15K qps, read heavy
  • geo index ~3 GB: in memory,
  • replicated
  • geohash: precision by radius,
  • own cell + 8 neighbours
  • haversine filter, then rank:
  • Bayesian rating + volume
  • - distance
  • details from cache + replicas
  • quadtree if density varies

Interview questions

Why can’t you just index latitude and longitude in SQL?

A query with ranges on both columns can only use an index efficiently for one of them, so it scans a thin band around the whole globe and filters the other column row by row. Mapping locations to one-dimensional cell keys such as geohashes solves this.

How does a geohash work?

It recursively halves the world, alternating longitude and latitude, and records one bit per split, encoding every five bits as a base-thirty-two character. Longer hashes are smaller cells, and points that share a prefix are in the same cell.

Why search neighbouring cells as well?

A user near a cell border can be closer to a place in the adjacent cell than to anything in their own cell, and neighbouring cells can have very different hashes. Searching the cell and its eight neighbours, then filtering by exact distance, avoids missing nearby results.

Geohash or quadtree?

Geohash is simple and can be stored in any key-value or SQL index, but its cells are a fixed size regardless of density. A quadtree splits busy areas into smaller cells so each leaf holds a similar number of places, at the cost of an in-memory structure you must build and update.

Do you need to shard the geo index?

Usually not: two hundred million businesses at about sixteen bytes each is around three gigabytes, which fits in memory on every search node. Replicate it for read scaling, and shard by region only if it grows a lot.

How do you rank results?

Filter candidates by category, price and opening hours, then score with a Bayesian average rating, a log-scaled review count and a distance penalty. The Bayesian average stops a place with a single five-star review from outranking a consistently good one.

How do you keep the average rating up to date?

Store a running sum of stars and a count per business, and update them asynchronously from review events, subtracting the old value on edits. Search may show a slightly stale average, while the author reads their own review from the primary.

How do new or edited businesses get into the index?

Database changes are streamed to the indexers, which update the affected cells within minutes. Because businesses change rarely, a periodic full rebuild is also cheap and corrects any drift.