How to Scrape Yelp Data (Listings and Reviews)
Scrape Yelp data the durable way: pull business name, rating, review count, category, address, phone, and hours from the page's JSON-LD, past PerimeterX.

To scrape Yelp data without your parser rotting in a month, skip the obfuscated React class names and read the schema.org JSON-LD block Yelp ships inside every business page. That one block hands you the name, rating, review count, price range, address, and phone as clean structured data, so most of the fields people fight over are already parsed for you. This guide walks the full pipeline for public Yelp business data and reviews: which fields to pull and where they live, how to page through search results and past the roughly 240-result cap, how to get through the PerimeterX bot wall, and how to collect reviews without stepping on privacy law. Every request uses SparkProxy's Scraping API, so browser rendering and residential IP rotation are request parameters instead of infrastructure you babysit.
Is scraping Yelp data legal?
Yelp business listings are public, so anyone can open a page without logging in. That settles the access question but not the permission question, and the two are separate, so get the framing straight before you write code.
In the United States, the Ninth Circuit's decision in hiQ Labs v. LinkedIn (2022) held that scraping data that's publicly accessible, meaning no login and no authentication wall, generally does not violate the Computer Fraud and Abuse Act. That ruling is about unauthorized access, not a license to copy whatever you like. Yelp's Terms of Service separately prohibit automated collection, and its robots.txt is unusually blunt about it: the file states that use of "any robot, spider ... or other automated device, process or means to access, retrieve, copy, scrape, or index any portion of the service ... is prohibited, except as expressly permitted by Yelp," and it only whitelists a few crawlers such as Googlebot on specific paths. So scraping Yelp can breach its terms and its stated crawl policy even where it clears the CFAA bar. Different questions, different answers.
Yelp also ships a sanctioned path: the Yelp Fusion API (developers.yelp.com) returns business search, business details, and reviews under a usage agreement and a daily call quota. There's one gap that pushes people toward scraping in the first place. The Fusion reviews endpoint returns at most three review excerpts per business, and each excerpt is truncated to roughly 160 characters. If your analysis needs full review text or every review on a listing, the API simply won't give it to you, and that is the narrow, gray-area space this guide operates in for research and analysis.
Guardrails that keep a Yelp project defensible:
- Collect public business data only: name, category, rating, review count, address, phone, hours, price. Nothing that sits behind a login.
- Treat reviews as personal data. A review ties a real name to an opinion, which is personal data under GDPR and CCPA. If you don't need reviewer identities, don't store them, and anonymize what you keep.
- Read and honor
robots.txtand anyCrawl-delay. Rate-limit hard and back off on errors so you never degrade the service for the people actually using it. - If the data feeds a commercial product, the Fusion API is the clean route, and you should run scraping past a lawyer. This is engineering guidance, not legal advice.
What data you can extract (fields reference)
A Yelp business page carries almost everything you'd want in a single embedded JSON-LD block, plus a couple of fields that live in the rendered HTML. The search results page is thinner: it's really a list of links to business pages, so the durable pattern is to harvest business aliases from search, then follow each one for the full record. Here's the reference set worth pulling, with the stable way to get each field as of mid-2026.
| Field | Where it lives | How to get it | Notes |
|---|---|---|---|
| Business name | JSON-LD `name`; page `h1` | Parse the `application/ld+json` block | JSON-LD is the most stable source |
| Business alias | canonical URL `/biz/ | `link[rel="canonical"]` or the `/biz/` href | Your stable per-business identifier |
| Rating | JSON-LD `aggregateRating.ratingValue` | float, 1.0 to 5.0 | Search cards also expose it in an `aria-label` |
| Review count | JSON-LD `aggregateRating.reviewCount` | integer | Matches the "N reviews" on the page |
| Category | JSON-LD `@type` plus category links | e.g. `Restaurant`, "Italian, Pizza" | Category anchors carry `find_desc=` |
| Price range | JSON-LD `priceRange` | `$`, `$$`, `$$$`, `$$$$` | Not always present |
| Address | JSON-LD `address` (PostalAddress) | street, locality, region, postal code | Cleanly split into fields |
| Phone | JSON-LD `telephone` | formatted string | |
| Hours | rendered hours table rows | day plus open/close text | Usually NOT in JSON-LD; needs render |
| Reviews (sample) | JSON-LD `review[]` | first page of reviews | Full set via `?start=` pagination |
Two rows are the reason this guide leans on JSON-LD. The rating, review count, address, phone, and price are handed to you already parsed inside the block, so you don't chase generated class names for any of them. Hours are the exception: Yelp renders them into an HTML table rather than the JSON-LD, so that one field needs the page rendered and a small table parser. More on why that split matters next.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Why Yelp is hard to scrape
Yelp punishes naive scrapers in ways a static page never would. Four things trip people up.
It's a JavaScript app. Yelp is a React front end. A plain requests.get() returns a shell, and the search cards and much of the page paint after the initial load. You need a real browser to render before there's a full DOM to read. The good news is that the JSON-LD block and the review text are server-rendered into the initial HTML, so once you're past the bot wall the parsing is straightforward.
PerimeterX / HUMAN bot defense. Yelp fronts its pages with PerimeterX (now HUMAN Security). Hit it from a datacenter IP or an obvious headless client and you get a "Press & Hold" challenge or a 403 with a px-captcha page instead of the listing. This is the single biggest reason Yelp scrapers fail, and it's why residential IPs plus a genuine browser fingerprint matter here more than on most targets.
Class names are obfuscated and rotate. The review card, the rating span, the business name link: their CSS classes are generated strings like css-1qn0b6x, and Yelp reshuffles them without warning. Hard-code them and your parser dies silently on the next deploy. The fix is to anchor on things Yelp can't freely scramble: the JSON-LD block (a web standard), link[rel="canonical"], /biz/ hrefs, and aria-label text.
Search paginates, and it's capped. Results come 10 to a page via a start offset, and a single search tops out around 24 pages, roughly 240 businesses, no matter how many exist. You cover a whole metro by running several narrower searches, not by paging forever.
| Signal | What you'll see | How to handle it |
|---|---|---|
| PerimeterX challenge | "Press & Hold" or a `px-captcha` 403 | Residential IPs plus a real rendered browser |
| Empty / shell HTML | JS didn't run | `render_js=true` so the page paints |
| Wrong region content | prices/results for another country | Match `country_code` to the target market |
| ~240-result cap | search ends far short of every business | Split into narrower searches by location |
| Selector returns nothing | class name rotated | Anchor on JSON-LD, `aria-label`, and `/biz/` hrefs |
A managed scraping API absorbs the rendering, the residential IP rotation, and the PerimeterX problem for you. The pagination and the JSON parsing stay yours, because they live in Yelp's page logic. For the proxy-side theory behind staying unblocked, How to Avoid Getting Your Proxy Blocked goes deep.
Set up the SparkProxy Scraping API
The SparkProxy Scraping API takes a target URL, runs it through a headless browser on a rotating proxy, and returns the rendered HTML. For Yelp, three parameters carry the weight:
render_js=true: Yelp is a React app, so let the browser paint the page. It also helps clear the PerimeterX challenge, which watches for headless tells.premium_proxy=true: routes through residential IPs. Datacenter IPs draw the "Press & Hold" wall fast on Yelp, so this is the parameter that decides whether you get data or a challenge page.country_code: the ISO alpha-2 exit country. Set it toUSfor US listings so the exit IP location matches the target market and you get the right prices and content.
The base endpoint is https://scrape.sparkproxy.io/api/v1 and auth is a single header, X-API-Key. A minimal request in cURL:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://www.yelp.com/biz/gary-danko-san-francisco" \
--data-urlencode "render_js=true" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "country_code=US"
The full parameter list and response fields live in the Scraping API docs. If you're weighing this against building and rotating your own residential pool, Web Scraping API vs Self-Managed Proxies lays out the trade-off honestly. The rest of the code in this guide reuses one small helper:
import requests
from urllib.parse import urlencode
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
def fetch(url):
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": url,
"render_js": "true",
"premium_proxy": "true",
"country_code": "US",
},
timeout=180,
)
resp.raise_for_status()
return resp.text
Scrape a Yelp search results page
A Yelp search URL is predictable. The search term goes in find_desc, the location goes in find_loc, and start is the pagination offset in steps of 10:
https://www.yelp.com/search?find_desc=<term>&find_loc=<location>&start=<offset>
start=0 is page one, start=10 is page two, and so on. Build the URL from the parts you control:
def yelp_search_url(term, location, start=0):
q = urlencode({"find_desc": term, "find_loc": location, "start": start})
return f"https://www.yelp.com/search?{q}"
html = fetch(yelp_search_url("pizza", "San Francisco, CA"))
The search page is React-hydrated, but the links to individual businesses are present in the rendered HTML, which is all you need from this surface. The search card shows a rating and a review count too, but they're wrapped in generated classes that rotate, so the reliable move is to treat search as a source of business aliases and pull the structured fields from each business page, where the JSON-LD lives.
Extract listings from the embedded JSON-LD
Here's the payoff for anchoring on standards. Yelp embeds one or more blocks, and the business page's block is a schema.org LocalBusiness (often Restaurant or FoodEstablishment) carrying the rating, review count, address, phone, and price. Parse those blocks once and you're done with class names for most fields. Use selectolax, a fast C-backed HTML parser: pip install selectolax.
import json
from selectolax.parser import HTMLParser
def json_ld_blocks(html):
"""Yield every parsed application/ld+json object on the page."""
tree = HTMLParser(html)
for node in tree.css('script[type="application/ld+json"]'):
try:
data = json.loads(node.text())
except (json.JSONDecodeError, ValueError):
continue
# A block can be a single object or a list of them.
yield from (data if isinstance(data, list) else [data])
def business_ld(html):
"""Return the LocalBusiness/Restaurant JSON-LD object, or None."""
wanted = {"Restaurant", "LocalBusiness", "FoodEstablishment"}
for obj in json_ld_blocks(html):
t = obj.get("@type", "")
types = t if isinstance(t, list) else [t]
if wanted.intersection(types) or "aggregateRating" in obj:
return obj
return None
To extract Yelp listings from a search page, harvest the business aliases from the /biz/ links. The business-name link is the anchor with real text; photo and menu links reuse the same alias and get deduped:
def parse_search_listings(html):
tree = HTMLParser(html)
seen, rows = set(), []
for a in tree.css('a[href^="/biz/"]'):
href = a.attributes.get("href", "")
alias = href.split("/biz/", 1)[1].split("?", 1)[0].strip("/")
name = (a.text() or "").strip()
if not alias or not name or alias in seen:
continue # skip photo/menu links, empty anchors, dupes
seen.add(alias)
rows.append({"alias": alias, "name": name})
return rows
The alias (for example gary-danko-san-francisco) is your stable identifier for a business. Everything downstream keys off it.
Scrape a single business page
Follow each alias to https://www.yelp.com/biz/, pull the JSON-LD, and flatten it into a clean record. This is where Yelp scraping is genuinely easy compared with a target like Google Maps, because the fields are pre-parsed:
def business_record(html, alias):
obj = business_ld(html)
if not obj:
return None
addr = obj.get("address", {}) or {}
rating = obj.get("aggregateRating", {}) or {}
return {
"alias": alias,
"name": obj.get("name"),
"category": obj.get("@type"),
"price_range": obj.get("priceRange"),
"phone": obj.get("telephone"),
"rating": rating.get("ratingValue"),
"review_count": rating.get("reviewCount"),
"street": addr.get("streetAddress"),
"city": addr.get("addressLocality"),
"region": addr.get("addressRegion"),
"postal_code": addr.get("postalCode"),
"url": f"https://www.yelp.com/biz/{alias}",
}
html = fetch("https://www.yelp.com/biz/gary-danko-san-francisco")
record = business_record(html, "gary-danko-san-francisco")
Hours are the one field the JSON-LD usually skips, so read them from the rendered hours table. Don't target the generated classes; anchor on the row shape and the day text instead:
DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
def parse_hours(html):
"""Hours live in a day/interval table, not the JSON-LD. Anchor on the
row shape and read text, since the surrounding classes are generated."""
tree = HTMLParser(html)
hours = {}
for row in tree.css("table tr"):
cells = [c.text().strip() for c in row.css("th, td")]
if len(cells) >= 2 and cells[0][:3] in DAYS:
hours[cells[0]] = cells[1]
return hours
record["hours"] = parse_hours(html)
Scrape Yelp reviews with pagination
Yelp seeds the business page's JSON-LD with a sample of reviews, usually the first page. That's the quick win for a handful per business:
def parse_reviews_ld(html):
"""Yelp seeds JSON-LD with a sample of reviews (usually the first page)."""
obj = business_ld(html) or {}
out = []
for r in obj.get("review", []) or []:
rating = r.get("reviewRating", {}) or {}
author = r.get("author")
out.append({
"author": author.get("name") if isinstance(author, dict) else author,
"rating": rating.get("ratingValue"),
"date": r.get("datePublished"),
"text": r.get("description"),
})
return out
To scrape Yelp reviews in full, page the business URL with the same start offset in steps of 10. Each review page re-renders its own 10 reviews into the HTML and reseeds the JSON-LD sample, so you can reuse the parser above per page. Add sort_by to control ordering:
import time
def scrape_all_reviews(alias, review_count, sort="date_desc"):
reviews, start = [], 0
while start < review_count: # Yelp pages 10 reviews at a time
url = f"https://www.yelp.com/biz/{alias}?start={start}&sort_by={sort}"
html = fetch(url)
page = parse_reviews_ld(html)
if not page:
break # blocked, or ran out of reviews
reviews.extend(page)
start += 10
time.sleep(1.5) # be polite; back off harder on errors
return reviews
There's a faster path if you need volume. Yelp's review widget calls an internal JSON endpoint, roughly https://www.yelp.com/biz/, which returns reviews as JSON you can parse without touching the DOM. It works, but it's undocumented and Yelp can change its shape or auth without notice, so treat it as a bonus, not a foundation you build on. Verify it against a live page before you rely on it.
One reminder that matters more here than anywhere else in the pipeline: review text carries a person's name, rating, and opinion together, which is personal data under GDPR and CCPA. Store the aggregate rating and review count freely; be deliberate about whether you keep reviewer identities and full text, and anonymize when you can. If you're collecting reviews to track sentiment over time, Using Proxies for Review Monitoring and Sentiment Analysis covers the analysis side of that work.
Beat the ~240-result cap with location tiling
A single Yelp search stops around 24 pages of 10, roughly 240 businesses, regardless of how many actually exist. For "pizza" in a big city that's a fraction of the real total. The way past it is the same move Yelp's own UI nudges you toward: run several narrower searches by neighborhood or sub-city, then dedupe by business alias.
def scrape_metro(term, locations, max_pages=24):
seen, rows = set(), []
for loc in locations: # narrower areas within one metro
for page in range(max_pages): # ~24 pages max per search
html = fetch(yelp_search_url(term, loc, start=page * 10))
listings = parse_search_listings(html)
if not listings:
break # end of results for this area
for r in listings:
if r["alias"] in seen: # same biz across overlapping areas
continue
seen.add(r["alias"])
rows.append(r)
time.sleep(1.5)
return rows
# Split one metro into narrower searches to beat the ~240-per-search cap:
sf = [
"Mission, San Francisco, CA",
"SoMa, San Francisco, CA",
"North Beach, San Francisco, CA",
"Castro, San Francisco, CA",
]
pizzerias = scrape_metro("pizza", sf)
Deduping on alias is what makes overlapping areas safe, since a business near a neighborhood boundary shows up under more than one search. Tune the granularity to the density you need: neighborhoods for a dense city, whole cities for a sparse region.
Scale without getting blocked
At volume, a few habits keep your run clean and your data complete:
- Rate-limit and back off. A short sleep between requests and exponential backoff on soft blocks does more for your success rate than any single clever trick. Detect the PerimeterX page and retry rather than saving a challenge page as data:
BLOCK_MARKERS = ("press & hold", "px-captcha",
"verify you are a human", "unusual activity")
def is_blocked(html):
low = html.lower()
return any(m in low for m in BLOCK_MARKERS)
- Keep concurrency modest. With a scraping API the provider rotates the exit IP per request, so your ceiling is your plan's rate limit rather than a pool of proxies you own and babysit. Don't fire hundreds of parallel requests at once; steady and moderate beats spiky.
- Match the region. Keep
country_code=USfor US listings so the exit IP and the content agree, which also reduces the odds of an interstitial. - Persist as you go. Write each business and its reviews to storage as they land instead of holding a whole metro in memory, so a mid-run block never costs you the batch.
The general high-volume patterns, concurrency, retries, and rotation, are covered in Using Datacenter Proxies for Web Scraping. For Yelp specifically, residential exits do the heavy lifting past PerimeterX, and the JSON-LD approach keeps your parser alive across Yelp's frequent front-end deploys.
Frequently asked questions
FAQ
Scraping publicly visible business listings (no login) generally does not violate the US Computer Fraud and Abuse Act under hiQ v. LinkedIn (9th Cir. 2022), but it can still breach Yelp's Terms of Service and its robots.txt, which prohibit automated access except for whitelisted crawlers. For contractually clean data, the Yelp Fusion API is the sanctioned path. Stick to public business data, treat reviewer identities as personal data under GDPR and CCPA, rate-limit so you don't degrade the service, and get legal advice before any commercial use.
Read the schema.org JSON-LD block Yelp embeds in each business page instead of targeting CSS classes. That application/ld+json script carries the name, rating, review count, price range, address, and phone already parsed, and because it's a web standard Yelp can't freely rename it the way it rotates its generated class names. Hours are the exception and come from the rendered hours table. This is what makes yelp business data scraping durable across Yelp's frequent front-end changes.
Yelp seeds the business page JSON-LD with the first page of reviews, and you page the rest by adding ?start=N in steps of 10, sorting with sort_by. Each page re-renders its own 10 reviews, so you loop until you reach the total review count. The Yelp Fusion API only returns three truncated excerpts per business, which is exactly why people scrape the full set, but remember review text is personal data, so be deliberate about what you store.
That's PerimeterX (HUMAN Security), Yelp's bot wall, and it triggers on datacenter IPs and obvious headless clients. Fix it with residential IPs and a real rendered browser: set premium_proxy=true and render_js=true on the SparkProxy Scraping API so requests arrive from a residential exit with a genuine browser fingerprint. Detect the challenge page in your code and retry instead of saving it as data.
A single Yelp search caps at roughly 24 pages of 10 results, about 240 businesses, no matter how many exist in that area. Paging further just returns the last page again. To cover a whole metro, split the query into narrower searches by neighborhood or sub-city, run each one, and dedupe the results by business alias. This tiling approach is the standard way to beat the cap when you extract Yelp listings at scale.
For a lot of use cases, yes, and it's the compliant choice. The gap is reviews: the Fusion reviews endpoint returns at most three excerpts per business, each truncated to about 160 characters, and it meters you with a daily call quota. If you need full review text, every review, or fields the API doesn't expose, scraping public pages is the fallback, subject to the legal guardrails above.
Get 50% off your first purchase
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon — claim it before it's gone
Related articles

How to Handle Cookies and Sessions When Web Scraping
Handle cookies and sessions when web scraping: cookie jars, Set-Cookie parsing, CSRF tokens, login state, disk persistence, and sticky proxy IPs, in Python.

Async Web Scraping in Python: A Concurrency Guide
Master async web scraping in Python with asyncio, httpx and aiohttp: bound concurrency with semaphores, size connection pools, and isolate failures.

How to Scrape Google News: RSS, Headlines, and Feeds
Scrape Google News with its free RSS feeds: pull headlines and articles, decode the redirected article links, and geo-target editions with hl, gl, and ceid.
