How to Scrape Google Maps Data (Listings and Reviews)
Scrape Google Maps data the durable way: pull business name, address, phone, rating, reviews, hours, and coordinates past the consent wall with a scraping API.

To scrape Google Maps data cleanly, you have to solve three problems most tutorials skip: the results panel is infinite-scroll and never paginates, Google caps a single search at roughly 120 results, and the CSS class names you'd normally target are obfuscated and rotate on a whim. Get those wrong and you ship a scraper that returns 20 businesses out of a city of 2,000, or one that breaks the week Google reshuffles its markup. This guide walks the full pipeline for public business data: which fields to pull, how to scroll the feed, how to read coordinates straight out of the place URL, how to clear the "unusual traffic" and EU consent walls, and how to tile the map so you actually cover the whole area. Every code sample uses SparkProxy's Scraping API, so browser rendering and the anti-bot layer are request parameters instead of infrastructure you babysit.
Is scraping Google Maps data legal?
Business listings on Google Maps are public, but "public" only answers one of the questions you need to answer, so set the framing before you write code.
In the United States, the Ninth Circuit's ruling 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 decision is about unauthorized access, not a license to do anything you like. Google's Terms of Service separately prohibit automated access and scraping of its services, so collecting data this way can be a breach of contract even where it isn't a CFAA violation. Those are two different legal questions.
Google also ships the sanctioned path: the official Google Places API returns the same business data under a usage agreement and a billing meter. If you need contractually clean data at scale for a product, that API is the compliant route, and scraping is the gray-area fallback for research, one-off analysis, or coverage the API doesn't sell. Know which one your use case needs.
Practical guardrails that keep a scraping project defensible:
- Collect public business data only: name, address, phone, category, hours, rating. Nothing behind a login.
- Be careful with reviews. A review ties a person's name and opinion together, which is personal data under GDPR and CCPA. If you don't need reviewer identities, don't store them, and anonymize what you keep.
- Don't hammer the servers. Rate-limit and back off on errors so you aren't degrading the service for real users.
- If the data feeds a commercial product, run it past a lawyer. This is engineering guidance, not legal advice.
Aggregating local business data for competitive mapping or lead research is a common, legitimate use. If you want the business context around collection like this, we cover it in Using Proxies for Market Research and Data Collection.
What data you can extract (fields reference)
A Google Maps search returns a feed of business cards, and each business has a place page with the full record. The two surfaces expose different depth: the search card carries the summary, the place page carries the details. Here's the reference set worth pulling, with the durable way to get each field as of mid-2026.
| Field | Where it lives | How to get it | Notes |
|---|---|---|---|
| Business name | Search card + place page | `a[href*="/maps/place/"]` `aria-label`; `h1` on place page | The aria-label is the most stable name source |
| Place URL | Search card | `a[href*="/maps/place/"]` `href` | Your stable per-business identifier |
| Coordinates | Place URL | regex `!3d | Already in the URL, no geocoding call needed |
| Rating | Search card + place | `span[role="img"][aria-label*="star"]` | e.g. "4.5 stars 1,240 reviews" |
| Review count | Search card + place | same star `aria-label` | Parse the number out of the label |
| Category | Search card + place | card text / category button | e.g. "Coffee shop" |
| Address | Place page | `button[data-item-id="address"]` `aria-label` | Prefixed "Address: ..." |
| Phone | Place page | `button[data-item-id^="phone:tel:"]` `aria-label` | Prefixed "Phone: ..." |
| Website | Place page | `a[data-item-id="authority"]` `aria-label` | The business's outbound URL |
| Opening hours | Place page | hours table rows / `button[data-item-id="oh"]` | Needs JS render, may need a click to expand |
| Plus code | Place page | `button[data-item-id="oloc"]` | Google's global grid reference |
Two of these rows are the reason this guide exists. The coordinates are sitting in the place URL as an !3d pattern, so you never pay for a separate geocoding call. And the address, phone, and website live on buttons with a data-item-id attribute, which is far more stable than the scrambled class names around them. More on why that matters next.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Why Google Maps is hard to scrape
Google Maps breaks naive scrapers in ways that a static e-commerce page never would. Four things trip people up.
It's a JavaScript app, not a document. The search feed, the place panel, the reviews, all of it is painted by JavaScript after the initial load. A plain requests.get() returns an almost empty shell. You need a real browser to render the page before there's anything to parse.
The results feed is infinite scroll. There is no &page=2. The left panel loads about 20 businesses, and the next batch appears only when you scroll that specific div[role="feed"] container to the bottom. If you don't drive the scroll, you get the first screen and nothing else.
Class names are obfuscated and rotate. The result card is div.Nv2PK, the link is a.hfpxzc, the rating text is span.MW4etd. Those strings are generated, and Google changes them without warning. Hard-code them and your parser dies silently on the next reshuffle. The fix is to anchor on things Google can't freely scramble because accessibility depends on them: aria-label, role, and data-item-id. Those are the load-bearing selectors.
Aggressive bot defense. Google flags datacenter IPs fast, serves a reCAPTCHA on a /sorry/index page when it suspects automation, and in the EU it bounces you to a cookie-consent interstitial before the map even loads.
| Signal | What you'll see | How to handle it |
|---|---|---|
| "Unusual traffic" page | reCAPTCHA served at `/sorry/index` | Rotate residential IPs, render with a real browser |
| Consent wall (EU) | Redirect to `consent.google.com` | Use a non-EU `country_code`, or preload a consent cookie |
| Empty feed | JS didn't run, or feed wasn't scrolled | `render_js=true` plus scroll the feed container |
| ~120-result cap | Feed ends far short of every business | Tile the map by a lat/lng grid |
| Selector returns nothing | Class name rotated | Anchor on `aria-label` / `role` / `data-item-id` |
A managed scraping API absorbs the rendering, IP rotation, and CAPTCHA problems for you. The scroll and the obfuscation are still yours to solve because they live in the page logic. For the proxy-side theory behind avoiding blocks, 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 hands back the rendered HTML. For Google Maps, four parameters carry the weight:
render_js=true: Maps is a JS app, so you need the browser to paint the feed and panel before there's HTML to parse.premium_proxy=true: routes through residential IPs, which survive Google's defenses where datacenter IPs draw a CAPTCHA quickly.country_code: the ISO alpha-2 exit country. Set it to a non-EU country likeUSand you skip theconsent.google.cominterstitial entirely.js_scenario: a list of browser actions (click, scroll, wait) that drives the infinite-scroll feed and opens the reviews tab.
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.google.com/maps/search/coffee/@40.7580,-73.9855,14z?hl=en" \
--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 your own headless-browser proxy pool, Web Scraping API vs Self-Managed Proxies lays out the trade-off honestly.
Scrape a Google Maps search results page
A Google Maps search URL has a predictable shape. The query goes in the path, the map center goes in the @lat,lng,zoom segment, and hl sets the language:
https://www.google.com/maps/search/<query>/@<lat>,<lng>,<zoom>z?hl=<lang>
The map center is the important part: Google returns businesses near those coordinates, so the same query at two different centers returns two different sets. Build the URL from the parts you control:
import requests
from urllib.parse import quote
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = "YOUR_API_KEY"
def maps_search_url(query, lat, lng, zoom=14, lang="en"):
q = quote(query)
return (f"https://www.google.com/maps/search/{q}/"
f"@{lat},{lng},{zoom}z?hl={lang}")
At zoom 14 you cover roughly a neighborhood. Zoom out and Google widens the area but still caps the count; zoom in and you get denser coverage of a smaller box. Hold the zoom constant across a run so your tiles are comparable.
Handle infinite scroll and the ~120-result cap
Loading the URL renders only the first ~20 cards. To get the rest, you have to scroll the feed container, and you drive that from the API with a js_scenario. The trick is to scroll the specific div[role="feed"] element, not the window, and to repeat it enough times to reach the end marker ("You've reached the end of the list").
import json
def scroll_scenario(rounds=15):
steps = [{"wait_for": "div[role='feed']"}]
scroll = {"evaluate":
"document.querySelector(\"div[role='feed']\").scrollTo(0, 1e6)"}
for _ in range(rounds):
steps.append(scroll)
steps.append({"wait": 2}) # let the next batch load
return {"instructions": steps}
def fetch_search(query, lat, lng, lang="en"):
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": maps_search_url(query, lat, lng, lang=lang),
"render_js": "true",
"premium_proxy": "true",
"country_code": "US",
"js_scenario": json.dumps(scroll_scenario()),
},
timeout=180,
)
resp.raise_for_status()
return resp.text
Now the honest limit almost no tutorial states plainly: one Google Maps search returns at most about 120 results, no matter how many you scroll for. Scroll 15 times or 50, the feed still ends around six batches in. That's a server-side cap on a single search viewport, not a scraping bug. For a query like "coffee" in Manhattan, 120 is a small fraction of what's actually there. The only way past it is to run many narrower searches and stitch the results together, which is the tiling section below. Setting rounds higher than ~15 just wastes render time once you've hit the wall.
Extract listings durably (aria-labels and href coordinates)
Here's where anchoring on stable attributes pays off. Instead of chasing div.Nv2PK, iterate every anchor that points at a place, and read the business name from its aria-label and the coordinates from its href. Use selectolax (a fast C-backed parser) for speed on large DOMs: pip install selectolax.
import re
from selectolax.parser import HTMLParser
COORD_RE = re.compile(r"!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)")
RATING_RE = re.compile(r"([0-5][.,]\d)\s*star", re.I)
REVIEWS_RE = re.compile(r"([\d,\.]+)\s*review", re.I)
def _star_label(link):
"""Climb from the place link to its card and read the rating aria-label."""
node = link.parent
for _ in range(4):
if node is None:
break
star = node.css_first('span[role="img"][aria-label*="star"]')
if star:
return star.attributes.get("aria-label", "")
node = node.parent
return ""
def parse_listings(html):
tree, seen, rows = HTMLParser(html), set(), []
for link in tree.css('a[href*="/maps/place/"]'):
name = link.attributes.get("aria-label")
href = link.attributes.get("href", "")
if not name or href in seen: # skip thumbnails and duplicates
continue
seen.add(href)
coord = COORD_RE.search(href)
label = _star_label(link)
rating = RATING_RE.search(label)
revs = REVIEWS_RE.search(label)
rows.append({
"name": name,
"place_url": href,
"lat": float(coord.group(1)) if coord else None,
"lng": float(coord.group(2)) if coord else None,
"rating": float(rating.group(1).replace(",", ".")) if rating else None,
"reviews": int(revs.group(1).replace(",", "").replace(".", "")) if revs else None,
})
return rows
Three things make this durable. The place link is matched by its href pattern, not a class, so a markup reshuffle doesn't touch it. The name comes from aria-label, which Google keeps intact for screen readers. And the coordinates come free from the !3d!4d pattern in the href, so you extract business data from Google Maps with a lat/lng already attached and skip a geocoding step other scrapers pay for. The rating and review count both live in one star aria-label string like "4.5 stars 1,240 reviews", which you split with two small regexes.
Scrape a single place: hours, phone, and reviews
The search card gives you the summary. For the full record, follow the place_url and parse the place panel. This is where data-item-id earns its keep: address, phone, and website hang off buttons carrying that attribute, and it survives the class churn.
def fetch_place(place_url, lang="en"):
sep = "&" if "?" in place_url else "?"
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": f"{place_url}{sep}hl={lang}",
"render_js": "true",
"premium_proxy": "true",
"country_code": "US",
"wait_for": "h1", # the place name headline
},
timeout=120,
)
resp.raise_for_status()
return resp.text
def _aria(tree, selector, prefix):
node = tree.css_first(selector)
if not node:
return None
label = node.attributes.get("aria-label", "")
return label.split(prefix, 1)[-1].strip() if prefix in label else (label or None)
def parse_place(html, place_url):
tree = HTMLParser(html)
name = tree.css_first("h1")
at = re.search(r"@(-?\d+\.\d+),(-?\d+\.\d+)", place_url)
return {
"name": name.text(strip=True) if name else None,
"address": _aria(tree, 'button[data-item-id="address"]', "Address:"),
"phone": _aria(tree, 'button[data-item-id^="phone:tel:"]', "Phone:"),
"website": _aria(tree, 'a[data-item-id="authority"]', "Website:"),
"plus_code": _aria(tree, 'button[data-item-id="oloc"]', "Plus code:"),
"lat": float(at.group(1)) if at else None,
"lng": float(at.group(2)) if at else None,
}
Pull the individual reviews
Reviews sit behind the "Reviews" tab and load with their own infinite scroll. Click the tab, then scroll the reviews list. Each review is wrapped in a div[data-review-id], and that attribute is stable, so it's your anchor.
def reviews_scenario(rounds=8):
steps = [
{"wait_for": "button[aria-label*='Reviews']"},
{"click": "button[aria-label*='Reviews']"},
{"wait": 2},
]
scroll = {"evaluate":
"let r=document.querySelectorAll('div[data-review-id]');"
"if(r.length)r[r.length-1].scrollIntoView();"}
for _ in range(rounds):
steps.append(scroll)
steps.append({"wait": 2})
return {"instructions": steps}
def parse_reviews(html):
tree, out = HTMLParser(html), []
for block in tree.css("div[data-review-id]"):
star = block.css_first('span[role="img"][aria-label*="star"]')
body = block.css_first("span.wiI7pd") # review text; verify selector
m = RATING_RE.search(star.attributes.get("aria-label", "")) if star else None
out.append({
"review_id": block.attributes.get("data-review-id"),
"rating": float(m.group(1).replace(",", ".")) if m else None,
"text": body.text(strip=True) if body else None,
})
return out
Fetch it by passing reviews_scenario() as the js_scenario on the place URL. The review body class (wiI7pd above) is one of the obfuscated ones, so treat it as a value to verify rather than a constant. And remember the guardrail from the legal section: reviewer names are personal data, so drop them unless you have a reason and a lawful basis to keep them.
Set language, region, and clear the consent wall
Two dials control what language and region you get, and one common wall blocks you before either matters.
Language is the hl query parameter. hl=en gives you English labels and English-language review snippets where available; hl=de, hl=fr, and so on switch the interface and the localized content. Set it explicitly instead of trusting a default, because the exit IP's country can otherwise pick the language for you.
Region is the map center. The @lat,lng in the URL is what actually decides which businesses come back, more than any country parameter, because Maps is inherently geographic. Pick coordinates inside the area you're targeting and you're set.
The consent wall is the one that quietly breaks EU scraping. Request Google Maps through an IP in the EU and Google redirects to consent.google.com before rendering the map, so your parser gets a cookie banner instead of businesses. The clean fix is to exit through a non-EU country: set country_code=US and the interstitial never appears. If you specifically need EU-local results, preload a consent cookie instead so the banner is already answered:
resp = requests.get(
API,
headers={"X-API-Key": API_KEY},
params={
"url": "https://www.google.com/maps/search/cafe/@52.5200,13.4050,14z?hl=de",
"render_js": "true",
"premium_proxy": "true",
"country_code": "DE",
"cookies": json.dumps([
{"name": "SOCS", "value": "CAI", "domain": ".google.com"},
]),
"js_scenario": json.dumps(scroll_scenario()),
},
timeout=180,
)
Detect the wall (and a soft block) before you trust any response, because both can come back with an HTTP 200:
def is_blocked(html):
low = html.lower()
markers = ("our systems have detected unusual traffic",
"/sorry/index", "recaptcha", "consent.google.com")
return any(m in low for m in markers)
Scale by tiling the map
The ~120-result cap means one search never covers a city. To get full coverage, split the target area into a grid of overlapping lat/lng centers, run the same query at each center, and dedupe by place_url. Each tile returns its own local ~120, and the union covers the whole area.
def grid(min_lat, max_lat, min_lng, max_lng, step=0.04):
lat = min_lat
while lat <= max_lat:
lng = min_lng
while lng <= max_lng:
yield round(lat, 4), round(lng, 4)
lng += step
lat += step
def scrape_area(query, box, lang="en"):
seen, results = set(), []
for lat, lng in grid(*box):
html = fetch_search(query, lat, lng, lang)
if is_blocked(html):
continue # rotate and retry upstream
for row in parse_listings(html):
if row["place_url"] in seen: # same shop appears in overlapping tiles
continue
seen.add(row["place_url"])
results.append(row)
return results
# Manhattan, roughly:
manhattan = (40.70, 40.80, -74.02, -73.93)
shops = scrape_area("coffee", manhattan)
Tune step to the density you're after. A smaller step means more overlap, fewer businesses missed at tile edges, and more requests; 0.03 to 0.05 degrees is a sane starting band for a dense city. Deduping on place_url is what makes the overlap safe, since a shop near a tile boundary shows up in several tiles.
At volume, add the usual hygiene: retry soft blocks with exponential backoff, keep concurrency modest so you don't spike one exit IP, and persist rows as you go rather than holding a city in memory. With a scraping API the provider rotates the exit IP per request, so your ceiling is your plan's rate limit rather than the number of proxies you own. The general high-volume patterns are in Using Datacenter Proxies for Web Scraping, and if you're aggregating location data into a product, Datacenter Proxies for Real Estate Data Aggregation covers the same tile-and-dedupe shape applied to another mapped dataset.
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 Google's Terms of Service, which prohibit automated access. For contractually clean data, the official Google Places API is the sanctioned path. Stick to public business data, treat reviewer identities as personal data under GDPR and CCPA, don't overload the service, and get legal advice before any commercial use.
A single Google Maps search is capped at about 120 results server-side, so scrolling more won't help. To cover a whole city, split the area into a grid of lat/lng map centers, run the same query at each center, and dedupe the results by place URL. Each tile returns its own local set, and the union gives you full coverage. This tiling approach is the standard way to beat the cap in Google Maps scraping.
The search card only has the summary, so follow each result's place URL and parse the place panel. The phone lives on a button[data-item-id^="phone:tel:"] and the website on an a[data-item-id="authority"], both readable from the element's aria-label. Those data-item-id attributes are stable, unlike the surrounding class names, so anchor on them.
Almost always one of three causes. Google Maps renders with JavaScript, so you need render_js=true and a real browser or you get an empty shell. The results feed is infinite scroll, so you must scroll the div[role="feed"] container to load beyond the first ~20. And in the EU, Google redirects to a consent page first, so set a non-EU country_code or preload a consent cookie to clear the wall.
Language is the hl query parameter on the URL (hl=en, hl=de, hl=fr), which controls interface labels and localized content. The region that decides which businesses appear is the @lat,lng map center in the URL, since Maps is geographic. Match the exit country_code to the target region too, so the IP location and the coordinates agree.
Yes, technically. Open a place, click the Reviews tab, and scroll the reviews list, which loads its own infinite scroll; each review is wrapped in a stable div[data-review-id] you can anchor on. Legally, tread carefully: a review links a person's name to their opinion, which is personal data under GDPR and CCPA, so avoid storing reviewer identities unless you have a lawful basis.
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 Scrape JavaScript-Rendered Websites in 2026
Scrape JavaScript-rendered websites reliably: why requests returns empty pages, and when to use XHR interception, a headless browser, or a render_js API.

How to Scrape Amazon Product Data at Scale
Scrape Amazon product data at scale: extract titles, prices, ASINs, ratings, and stock, beat the Robot Check, and handle geo price variance with a scraping API.

Bypass Cloudflare Web Scraping: An Ethical 2026 Guide
Bypass Cloudflare web scraping blocks the ethical way: TLS/JA3 fingerprints, JS challenges, Turnstile, plus real-browser and SparkProxy Scraping API fixes.
