Designing Yelp
A walkthrough of building a local-business search & review service — from requirements to a fast, geospatial search engine — following the Hello Interview delivery framework.
Yelp is overwhelmingly read-heavy — people search and browse constantly, but rarely write a review (as much as a 1000:1 read:write ratio). The whole design flows from this: make search fast, keep writes simple, and resist adding infrastructure the scale doesn't justify. Demonstrating that restraint is what separates a staff-level answer from an over-engineered one.
1Understanding the Problem
An online platform where users search for and review local businesses, restaurants, and services — e.g. "find all Italian restaurants near me."
Functional requirements — the features
These are the "users should be able to…" statements. The instructor limits scope to the three that matter, and pins down exactly what each means before moving on.
- Search for businesses by name, location (lat/long), and category.
- View a business and its reviews.
- Leave a review on a business — a mandatory 1–5 star rating plus optional text.
Out of scope (below the line): admins adding/editing businesses, viewing businesses on a map, and personalized recommendations.
If the interviewer hands you the functional requirements, take them — but still ask a couple of clarifying questions. Real Yelp lets you search by menu items and much more; agree on what's out of scope so you don't drown. Here, search is limited to location, name, and category.
Scale — ask before you design
Before non-functional requirements, the instructor pins down scale by asking the interviewer. The agreed "Yelp-like scale" numbers are worth memorizing because every later trade-off leans on them:
10M businesses · 100M daily active users. Each business averages ~100 reviews at ~1 KB each ⇒ roughly 1 TB total. (Businesses alone: 10M × 1 KB = 10 GB.) These are close to real Yelp's ~7–8M claimed businesses.
Non-functional requirements — the qualities
These are the "-ilities" that make the system challenging. The trick is to name the unique ones, not generic ones — "everything should be low latency" is not interesting; identifying which operation is the latency risk is.
Availability over consistency (CAP)
Partition tolerance is a given, so the real choice is consistency vs. availability. The question to ask yourself: does every read need the very latest write? For Yelp, no.
Imagine someone in Japan posts a review; you load the business a second later and don't see it yet — no harm. Or a business updates its description and you briefly see the old one — no harm. We'd rather show slightly stale data than an error or nothing. So we prioritize availability; eventual consistency is tolerable for businesses and reviews.
Low-latency search (< 500 ms)
Search is the operation that is naturally high-latency if not optimized, so this is the requirement that actually shapes the design. Target: < 500 ms.
~200 ms feels instant to humans; ~1 s starts to feel slow. 500 ms is a comfortable target for a search round-trip.
Scalability
The system must handle the 100M DAU / 10M businesses above. (Below the line: GDPR/data protection, fault tolerance, spam/abuse protection.)
The constraint
Each user can leave only one review per business. Rationale: stop a single user (or a competitor) from spamming hundreds of 1-star reviews to tank a rating. Handled in the deep dives, alongside the non-functional requirements.
2Core Entities & the API
Core entities — the nouns
Start with a broad list of the primary entities (roughly the tables you'll persist and the data your API exchanges). Don't nail down every column yet — you'll have a clearer picture once you reach the high-level design.
- Business — the most important entity: name, location, category, average rating.
- Review — a rating (+ optional text) left by a user on a business.
- User — searches, views, and (when authenticated) leaves reviews.
Tell the interviewer: "I'll list the core entities now and flesh out the actual columns during the high-level design, when I understand them better." Outlining the full data model up front is also fine — do whichever you prefer, just say so.
The API — one endpoint per requirement
Walk the functional requirements one by one; each usually maps to an endpoint (sometimes two).
Search for businesses
// Search for businesses — GET because we're reading resources
GET /businesses?query&location&category&page -> Business[]
Search filters (query, location, category) are optional, and optional inputs belong in query params, not the path. And any endpoint that can return a huge set — up to 10M businesses here — must paginate (e.g. page 1, 20 results), or you ship an enormous, slow payload over the network.
View a business + its reviews
The simple version returns both at once:
GET /businesses/:businessId -> Business & Review[]
Acceptable, especially at mid-level. But it's not optimal — reviews may be numerous (needs its own pagination) and slower to load than the core business details. Splitting into two endpoints lets the client load them in parallel and lazy-load reviews under a skeleton loader:
GET /businesses/:businessId -> Business GET /businesses/:businessId/reviews?page= -> Review[]
:businessId is a path parameter — required; you can't fetch a business without one. Search filters were query params — optional.
Leave a review
// First POST endpoint — creates a new resource, nested under its business POST /businesses/:businessId/reviews { rating: number, // 1–5, required text?: string // optional }
This is the first authenticated endpoint (search/view stay open so anonymous users can browse). Auth travels in the header via a session token or JWT. You may include a userId in the body — that's not inherently wrong — but you must validate it server-side against the authenticated identity. Never trust the client-supplied ID on its own.
3High-Level Design
Goal of the high-level design: satisfy the functional requirements with the simplest architecture that works. Go endpoint by endpoint; layer depth later in the deep dives.
Two services behind one gateway, sharing a single database. Recreated from the source's high-level diagram.
3.1 · Satisfying each requirement
Search & view. The gateway forwards to the Business Service, which builds a query against the database. Both search and view are read-heavy and closely related, so they live in the same service. A naive search query looks like:
SELECT * FROM businesses WHERE name LIKE '%coffee%' AND lat > ? AND lat < ? AND long > ? AND long < ? AND category = ?;
Two criteria: (a) is the functionality closely related (same team)? and (b) do the parts need to scale independently because of very different read/write patterns? Search and view share read patterns → keep together. Reviews are write-heavy and rare → the instructor gives them their own Review Service.
If you already know search needs Elasticsearch/PostGIS, say so now — "I know this query is inefficient; I'll introduce a search-optimized store in the deep dive" — and move on. The linear build-up is to organize your thinking, not to pretend you don't know the answer. Just don't rat-hole for 20 minutes on search this early.
Leave a review. The Review Service writes to the database. The review model:
Review { id, userId, businessId (FK), rating 1–5, text? }
3.2 · One database, or one per service?
The instructor's answer: a "resounding maybe." Microservice purists insist on separation, but many real systems share a database and it's often the simpler, arguably correct choice.
The justification for sharing here:
- Tiny data. ~1 TB fits comfortably on a single modern Postgres instance (which can handle 5–100+ TB) — no sharding needed.
- Tight coupling. Reviews and businesses are frequently joined; one database avoids cross-service joins.
The counter-argument — fault isolation (if the review DB dies, search shouldn't die with it) — is valid, but here it's mitigated with simple replication / read replicas rather than a full split.
Bias toward simplicity unless you can articulate a clear benefit to added complexity. Trade-offs like this have no single right answer — what matters is the justification you give.
4Deep Dive · Average Rating in Search Results
Search results show a partial business — name, hours, and crucially the average rating, which is the first thing users look at. Computing that average on the fly for every search would be brutal. The deep dive walks three solutions, from bad to great.
BadCompute on the fly
SELECT b.business_id, b.name, b.location, b.category, AVG(r.rating) AS average_rating FROM businesses b JOIN reviews r ON b.business_id = r.business_id GROUP BY b.business_id;
Simple, no extra infra — but it re-runs an expensive JOIN + aggregate on every search, even when no new reviews arrived. A popular business can have hundreds of thousands of ratings to aggregate. At our scale this kills read performance. The fix: do the aggregation on write, not on read.
GoodPrecompute with a cron job
A periodic job (hourly/daily) computes each business's average and writes it to an average_rating column on the businesses table. Now search just reads the column.
A business with 2 reviews averaging 2.0 gets your fresh 5-star rating — but the average won't move for hours or a day. Confusing. Whether that's acceptable is a product decision to raise with the interviewer.
GreatSynchronous update + concurrency control
Update the average as each review arrives. Add a num_reviews column so the new average is a cheap incremental formula (a few CPU cycles):
new_avg = (old_rating × num_reviews + new_rating) / (num_reviews + 1)
Wrap the "insert review + update business" in a single transaction (atomic — all or nothing). But two concurrent reviews can still clobber each other:
Business A: 100 reviews, avg 4.0. User 1 and User 2 both read that state. User 1 (5★) computes 4.01; User 2 (3★) computes 3.99. User 1 writes 4.01, then User 2 overwrites with 3.99. The correct answer accounting for both is 4.00 — User 1's review is lost.
Two fixes, both fine:
- Pessimistic row lock —
SELECT … FOR UPDATEthe business row; the second transaction waits its turn. Ordering becomes strictly before/after. - Optimistic concurrency control (OCC) — re-check
num_reviewsbefore writing; if it changed, someone else wrote, so retry. No lock contention; ideal when concurrency is low (as it is here).
-- pessimistic, in one transaction BEGIN; lock business row (business_id); INSERT review; UPDATE num_reviews, average; COMMIT; -- lock released
Several commenters argue the lost update can't happen with a single atomic UPDATE … SET avg = (avg*num + rating)/(num+1), num = num+1, because Postgres takes a row-level lock and re-evaluates the row on the second updater (Read Committed). Others counter that a race still exists depending on where the read happens. Evan King's response cuts through it: "in an interview the decision matters less than how you justify it." A common production alternative is an AFTER INSERT trigger or INSERT … ON CONFLICT upsert. Preserve this as an open, legitimately debated point.
Many mid/senior candidates add a queue: reviews → queue → worker recomputes the average. The instructor's pushback: what are you buffering for? Do the math — 10M businesses, ~100k writes/day ≈ 1 write/second; even a wild 1000× peak is ~1000 writes/s, which a single Postgres instance handles easily. The queue is unnecessary complexity. Recognizing this — that write throughput is tiny — is the hallmark of a staff candidate.
5Deep Dive · One Review per User per Business
BadApplication-level check
def leave_review(user_id, business_id): reviews = db.get_reviews_for_business(business_id) if user_id in reviews: return "User has already reviewed this business" else: db.leave_review(user_id, business_id)
It only holds if every writer runs the check. As the company grows, another service, a different code path, or a data-engineering backfill (say, after an acquisition) writes reviews without knowing about your if statement — and the constraint silently breaks. It also has a race condition: two simultaneous submissions can both pass the check.
GreatEnforce it in the database
ALTER TABLE reviews ADD CONSTRAINT unique_user_business UNIQUE (user_id, business_id);
Now a duplicate insert throws an error the app catches and returns to the client. Even the earlier race resolves cleanly: one writer wins, the second hits the unique-constraint error (as long as the data is on one instance — which it is).
Whenever you have a hard data constraint, enforce it as close to the database as possible. This is the "C" (consistency/correctness) in ACID. It guarantees the invariant regardless of which application, path, or engineer does the write.
6Deep Dive · Making Search Efficient
This is the crux of the interview — spend the most time here.
6.1 · Why the naive search is slow
// This query sucks. Very very slow. SELECT * FROM businesses WHERE latitude > 10 AND latitude < 20 AND longitude > 10 AND longitude < 20 AND name LIKE '%coffee%';
It forces a full table scan: the lat/long bounding box has no useful index, and LIKE '%coffee%' must inspect every row's name. Across 10M businesses that blows past the 500 ms budget.
BadJust add a B-tree on lat & long
CREATE INDEX idx_lat_long ON businesses (latitude, longitude);
Feels right, but a B-tree isn't built for 2-D data. Here's the instructor's diagram of why:
A B-tree can fetch each range, but each is a massive strip. You then intersect two huge strips to get the small target box — still very expensive at scale.
B-trees are excellent for single-dimension ranges and exact matches, but they have no spatial awareness — they treat lat and long as independent values. For real geographic search you need a purpose-built structure: geohashes, quadtrees, or R-trees.
Three filters ⇒ three index types
| Filter | Needs | Why |
|---|---|---|
| Location | Geospatial index (geohash / quadtree / R-tree) | 2-D proximity that B-trees can't do |
| Name | Inverted index (full-text) | Maps word → businesses; avoids scanning every row |
| Category | B-tree index | Simple exact-match — B-trees are perfect here |
A hash-map from word → list of documents (businesses) containing it. Searching "coffee" becomes a direct lookup of the "coffee" key instead of a full-table LIKE scan.
6.2 · GreatElasticsearch
A search-optimized database that natively supports all three index types at once — geospatial (with radius), inverted (full-text), and B-tree exact match — so a single query does location + name + category together.
{
"query": { "bool": { "must": [
{ "match": { "name": "coffee" } },
{ "geo_distance": { "distance": "10km",
"location": { "lat": 40.7128, "lon": -74.0060 } } },
{ "term": { "category": "coffee shop" } }
]}}
}
It's not built for transactional integrity / full ACID, and its fault tolerance needs careful config to avoid data loss. Keep Postgres as the source of truth; use Elasticsearch only for search.
That leaves two stores to keep in sync. Use Change Data Capture (CDC):
Every DB change is emitted as an event; a consumer replays it into Elasticsearch. Interviewers usually accept the single CDC arrow as an abstraction.
6.3 · GreatPostgres with extensions
A staff-level move: avoid a whole new technology (and the CDC sync problem) by using Postgres extensions that provide the same indexes in-place.
CREATE EXTENSION IF NOT EXISTS postgis; -- geospatial: quadtrees, geohashes, R-trees CREATE EXTENSION IF NOT EXISTS pg_trgm; -- full-text search
PostGIS + pg_trgm + Postgres's built-in B-trees cover all three filters directly.
Elasticsearch excels at high scale — but we don't have high scale (10M businesses, ~1 TB). Introducing it means a new store, a sync pipeline that can break, and a team that must learn to operate/shard it. Extensions avoid all of that. PostGIS may lag Elasticsearch on full-text at very large scale, but that's not our situation. (Note: one commenter felt search should include Elasticsearch from the start; the write-up's final diagram does use ES.)
6.4 · Hand-rolling the geospatial index
Some interviewers (the instructor names OpenAI) want you to zoom into geospatial indexing itself — no off-the-shelf store. They're looking for three things:
(1) Pick and justify an index strategy. Usually weigh geohashing vs. quadtrees (R-trees if you know them). The instructor prefers quadtrees here: updates are infrequent and businesses cluster into dense regions like NYC.
(2) Second-pass filtering. The index returns a coarse square; you then filter in memory to the exact radius:
Stage 1: geospatial index → crude square. Stage 2: discard the corners outside the true circle using the Haversine formula (Pythagoras on a sphere).
(3) Sequence the filters to shrink the search space fastest. Distance is usually the most restrictive filter, so apply it first; then run name/category on the much smaller result set. (Sometimes name/category first is better — depends on the query; ask the interviewer.)
7Deep Dive · Searching by Location Name
Only asked of staff candidates (or seniors who flew through everything else). Real Yelp lets you search "Italian in Santa Monica" — a named place, not a radius.
The key realization: a city or neighborhood isn't a circle — it's an irregular polygon (a neighborhood like "The Mission" in SF, not just a zip or city). Everything built so far handles circles/radii, which is now insufficient. You need two pieces:
- Location name → polygon. Add a
locationstable:name,type(city / neighborhood), andboundary(a list of lat/long points). Populate from public datasets like GeoJSON / Geoapify; index the name column. - Polygon → businesses inside it. Both Elasticsearch and PostGIS support this natively — geo_shapes (PostGIS) / geo_points & geo_bounding_box (Elasticsearch).
{ "query": { "geo_bounding_box": { "location": {
"top_left": { "lat": 42, "lon": -72 },
"bottom_right": { "lat": 40, "lon": -74 }
}}}}
Running an expensive polygon lookup on every search for "Santa Monica" is wasteful. Instead, when a business is created, compute which named areas contain it and store them as a list on the business — e.g. ["bay_area", "san_francisco", "mission_district"]. Search then becomes a cheap B-tree / keyword lookup on the location string.
{
"id": "123",
"name": "Pizza Place",
"location_names": ["bay_area", "san_francisco", "mission_district"],
"category": "restaurant"
}
Same pattern as the average rating: shift work from read to write. It's justified because locations barely change — a business stays in Santa Monica for years. If its address does change, a cron job recomputes that business's location set. Reads are frequent; recomputation is rare.
8What's Expected at Each Level
| Level | What the interviewer wants |
|---|---|
| Mid-level | A working high-level design; reasonable answers on average rating and search optimization. Doesn't need to know DB constraints or index internals, but should problem-solve toward pushing the constraint to the persistence layer and apply the "correct" technologies. |
| Senior | Nails most deep dives (except location-name search). Watched for over-engineering — wants strong justifications. Should know the index types and weigh technology trade-offs. |
| Staff+ | Recognizes key insights and derives simple solutions: Postgres extensions instead of Elasticsearch (avoids a new tech + the sync problem); tiny write throughput ⇒ no message queue; small data ⇒ replica/cache, no sharding. Can describe when the complex option would be needed, but argues why simple suffices here. |
Seniority is shown less by adding components and more by doing the math, choosing the simple option, and justifying it — knowing that a queue, a separate DB, or Elasticsearch could be right, but explaining why they aren't necessary at this scale.
+A Note on Scaling
Pulling the scaling thread together (it recurs throughout the deep dives): 100M DAU is significant but not extreme. Standard moves apply — horizontally scale the stateless Business/Review services behind load balancers, add read replicas for the read-heavy load. Sharding isn't needed (real Yelp has far more data and would shard; our scoped version fits one instance — and saying so, with the math, is a "staff-y" move).
Don't naively cache search results — they key on the user's exact lat/long + radius, so hit rates are poor. Instead cache business details and reviews (hit via the view endpoint), keeping only the minimum fields in Elasticsearch for search. That's what actually helps read scalability.
★Active Recall
Reconstruct the answers before expanding. If you can't, revisit the linked section.
Why does Yelp choose availability over consistency? Give a concrete example.
Reads don't need the latest write. If a review or a business-description update takes a few seconds to appear (e.g. a review posted in Japan, or an edited description), no one is harmed. Showing slightly stale data beats showing an error or nothing — so we prioritize availability with eventual consistency. (§1)
A B-tree index on (latitude, longitude) exists. Why is geographic search still slow?
B-trees have no spatial awareness — they treat lat and long as independent 1-D values. Each range query returns a huge strip; you must intersect two enormous strips to isolate the small target box. You need a geospatial index (geohash/quadtree/R-tree) instead. (§6.1)
Name the three filters in Yelp search and the index each requires.
Location → geospatial index (geohash/quadtree/R-tree); Name → inverted index (full-text); Category → B-tree (exact match). Elasticsearch or Postgres+PostGIS+pg_trgm can serve all three. (§6.1–6.3)
You introduced Elasticsearch. What new problem appears, and how do you solve it?
Now two stores must stay consistent, and Elasticsearch shouldn't be the primary DB (weak on ACID/durability). Keep Postgres as source of truth and sync changes into Elasticsearch via Change Data Capture (CDC): DB change events → stream (Kafka) → worker → Elasticsearch. (§6.2)
When would you pick Postgres extensions over Elasticsearch?
When scale is modest (here ~1 TB). PostGIS + pg_trgm give geospatial + full-text in one store, avoiding a new technology, the CDC sync pipeline, and the operational burden. Elasticsearch earns its place mainly at high scale / heavy full-text. (§6.3)
Why is a message queue for updating average ratings usually unnecessary here?
Do the math: ~100k writes/day ≈ 1 write/second; even a 1000× peak is ~1000/s, which a single Postgres instance handles. There's nothing to buffer — the queue adds complexity without benefit. Spotting this signals staff-level judgment. (§4)
Two reviews for the same business arrive at once. What breaks, and what are the two fixes?
Lost update: both read the same num_reviews/avg, compute new averages, and the second write overwrites the first. Fix with (a) a pessimistic row lock (SELECT … FOR UPDATE), or (b) optimistic concurrency control — recheck num_reviews before writing and retry if it changed. (§4)
Why enforce "one review per user per business" in the database, not the app?
An app-layer check only holds if every writer runs it — future services, code paths, or data backfills will bypass it, and it has a race condition. A DB UNIQUE(user_id, business_id) constraint guarantees the invariant no matter who writes, and cleanly resolves the race (one winner, the other errors). (§5)
How do you support searching by a place name like "Santa Monica"? Why precompute?
A place is an irregular polygon, not a radius. Store name→polygon in a locations table (from GeoJSON); use geo_shape/geo_bounding_box to find businesses inside. Then precompute each business's containing areas at creation time (a location_names list) so search is a cheap keyword lookup — justified because locations rarely change. (§7)
What is second-pass filtering and when is it needed?
When hand-rolling geospatial search, the index returns a coarse square. Second-pass filtering discards the corners outside the true circular radius using the Haversine formula. Sequence filters to cut the search space fastest — usually distance first, then name/category. (§6.4)
↺Quick Revision
Must remember
- Read:write ≈ 1000:1 → optimize reads, keep writes simple.
- Scale: 10M businesses, 100M DAU, ~1 TB.
- Search target: < 500 ms.
- Availability > consistency; eventual is fine.
- Constraint: 1 review / user / business.
Rules & formulas
- Incremental avg:
(old·n + new)/(n+1). - Enforce constraints at the persistence layer (UNIQUE).
- Aggregate on write, not on read.
- Bias to simplicity unless complexity is justified.
Three filters → three indexes
- Location → geospatial (geohash/quadtree/R-tree)
- Name → inverted (full-text)
- Category → B-tree (exact)
Common mistakes
- B-tree on lat/long → strip-intersection, still slow.
- Elasticsearch as primary DB → durability risk.
- Message queue for 1 write/sec → needless.
- App-layer uniqueness check → bypassable + racy.
- Caching raw search results → poor hit rate.
Solution ladder (bad → great)
- Avg rating: on-the-fly JOIN → cron precompute → sync update + lock/OCC.
- Search: full scan → B-tree (fails) → Elasticsearch + CDC / Postgres extensions → hand-rolled quadtree + second-pass filter.
- One review: app check → DB unique constraint.
- Place names: polygon lookup per request → precompute containing areas on write.
Study notes compiled from the Hello Interview "Design Yelp" breakdown (primary) and its video walkthrough (instructor commentary). Community-debated points (e.g. whether optimistic locking is strictly necessary in Postgres) are flagged as open, not resolved.