How to Scrape Google Maps (2026 Guide)
Collect business listings, ratings and reviews from Google Maps reliably — without maintaining a browser farm.
Why Google Maps breaks naive scrapers
Fetch a Maps search URL with `curl` and you get roughly 400 KB of JavaScript, a loading spinner and zero business names. Everything you actually want arrives afterwards, over a set of internal RPC endpoints, and gets painted into a virtualized list that only holds the ~20 rows currently near the viewport. There is no page 2 to request: the result set grows as you scroll, and rows you already passed are recycled out of the DOM.
So the first thing to accept is that this is a browser job, not an HTTP job. The second thing to accept is that a plain headless browser is not enough either. Maps is one of the most aggressively instrumented surfaces on the web — it watches pointer movement, scroll velocity, WebGL and canvas entropy, and it correlates all of that with the IP's reputation. A default Puppeteer install on a datacenter IP typically gets a few dozen searches in before every subsequent request returns a consent wall or an empty result panel.
The third thing, and the one that quietly ruins datasets: Maps results are personalized by location. The same query issued from Frankfurt and from Ohio returns different businesses in a different order, with distances measured from different origins. If your proxy pool picks IPs at random, your "Austin dental clinics" dataset is really a blend of a dozen viewpoints, and you will not notice until someone asks why the row count changes every run.
Should you just use the Places API?
Sometimes, yes — and it's worth being honest about that instead of selling you a crawl. The official API is stable, has clear terms, and if your use case fits inside its limits it is the lower-maintenance choice.
Where it stops fitting is coverage. Places caps how many results a single search returns and how many reviews come back per place, and the cost model is per-call, so exhaustive territory mapping — every locksmith in every postal code of a metro area — gets expensive fast and still doesn't give you the long tail. Teams usually land on a hybrid: the API for authoritative place IDs and opening hours, a crawl for the breadth and the review corpus.
- Places API: authoritative, rate-limited, capped result sets, per-call pricing.
- Crawling: full visible result set, full review lists, cost scales with pages rendered.
- Hybrid: resolve place IDs via the API, enrich depth by crawling those specific place URLs.
The approach that works
Render the search URL in a real browser, scroll the results panel until its scroll height stops changing, then read the settled DOM. Pin the IP to the region you are searching so the ranking matches what a local user sees. Then extract by describing the shape of the output rather than writing selectors against Google's obfuscated class names, which change without warning and are not stable across locales.
With Irmu that is one call — the scroll loop, proxy selection, fingerprint and extraction all happen server-side:
import os
import requests
API = "https://app.irmu.com/api"
HEADERS = {"Authorization": f"Bearer {os.environ['IRMU_API_KEY']}"}
def search(query: str, country: str = "us", city: str | None = None):
res = requests.post(
f"{API}/extract",
headers=HEADERS,
json={
"url": f"https://www.google.com/maps/search/{requests.utils.quote(query)}",
"country": country,
"city": city,
"render": True,
"scroll": {"selector": "div[role='feed']", "until": "stable", "max_ms": 45000},
"schema": {
"business_name": "string",
"category": "string",
"address": "string",
"phone": "string",
"website": "string",
"rating": "number",
"review_count": "number",
},
},
timeout=120,
)
res.raise_for_status()
return res.json()["data"]
for row in search("dental clinics in austin", country="us", city="austin"):
print(f"{row['rating']:>3} {row['review_count']:>5} {row['business_name']}"){
"status": 200,
"credits": 10,
"data": [
{
"business_name": "Zilker Family Dentistry",
"category": "Dentist",
"address": "1500 Barton Springs Rd, Austin, TX 78704",
"phone": "+1 512-555-0148",
"website": "https://example-dental.com",
"rating": 4.8,
"review_count": 412
}
],
"meta": { "results": 87, "scrolled_ms": 21400, "country": "us" }
}The scroll loop, if you're building it yourself
Plenty of teams already have a browser farm and only want the network layer fixed. If that's you, the pattern below is the one that has held up: scroll the feed element rather than the window, wait for the height to stop growing across two consecutive checks, and stop on the "You've reached the end of the list" sentinel that Maps injects.
Two details people miss. First, scroll the container — `window.scrollTo` does nothing here because the feed is its own scroll context. Second, harvest rows as you go. Because the list is virtualized, rows scrolled far enough out of view are destroyed, and a single `querySelectorAll` at the end will return only what survived.
const FEED = "div[role='feed']";
const seen = new Map();
async function harvest(page) {
const rows = await page.$$eval(`${FEED} > div > div[jsaction]`, (els) =>
els.map((el) => ({
name: el.querySelector("[class*='fontHeadline']")?.textContent?.trim(),
href: el.querySelector("a[href*='/maps/place/']")?.href,
})).filter((r) => r.name && r.href),
);
for (const r of rows) seen.set(r.href, r); // href is the stable key
}
let lastHeight = 0;
for (let i = 0; i < 60; i++) {
await harvest(page);
const height = await page.$eval(FEED, (el) => {
el.scrollTop = el.scrollHeight;
return el.scrollHeight;
});
if (height === lastHeight) break; // list settled
lastHeight = height;
await page.waitForTimeout(1200 + Math.random() * 600); // human-ish cadence
}
console.log(`${seen.size} unique places`);Deduplicate on the place URL, never on the business name. Chains, suite numbers and localized transliterations will collapse rows you wanted to keep.
Getting reviews, not just listings
Review text lives one layer deeper: you open the place panel, switch to the Reviews tab, sort by newest, expand each truncated body, and scroll that panel too. It is the same virtualized-list problem again, nested inside the first one, which is why review scraping is where homegrown Maps crawlers usually stall.
Point the same extract call at a place URL and ask for an array, and the expansion happens before extraction. Expect this to cost more than a listing page — you are paying for a rendered session with several interactions, not a single fetch.
def reviews(place_url: str, limit: int = 200):
res = requests.post(
f"{API}/extract",
headers=HEADERS,
json={
"url": place_url,
"country": "us",
"render": True,
"actions": [
{"click": "button[aria-label*='Reviews']"},
{"click": "button[aria-label*='Sort']"},
{"click": "div[role='menuitemradio']:nth-child(2)"}, # Newest
{"scroll": {"selector": "div[role='main']", "until": "stable"}},
{"click_all": "button[aria-label='See more']"},
],
"schema": {
"reviews": [
{
"author": "string",
"rating": "number",
"relative_date": "string",
"text": "string",
"owner_reply": "string|null",
}
]
},
"limit": limit,
},
timeout=180,
)
return res.json()["data"]["reviews"]Running it on a schedule without melting anything
Ratings move slowly. Review counts move daily. Phone numbers and websites change a few times a year. Refreshing everything nightly is how a Maps pipeline turns into a five-figure line item for data that did not change.
Tier it. Re-crawl the search grid weekly to catch new and closed businesses; re-crawl review pages nightly only for the places you actively track; and keep a `last_seen` timestamp so a place that disappears from three consecutive grid runs gets flagged as closed instead of silently vanishing from your table.
# Weekly discovery across a postcode grid, one crawl per cell
for zip in 78701 78702; do
curl -G https://app.irmu.com/api/crawl \
-H "Authorization: Bearer $IRMU_API_KEY" \
--data-urlencode "url=https://www.google.com/maps/search/dentist+$zip" \
--data-urlencode "premium=true" \
--data-urlencode "country=us" \
--data-urlencode "ai_query=List each business with name, address and rating as JSON" \
-o "maps-$zip.json"
doneWhat this costs, honestly
A rendered Maps search with a long scroll is not a cheap request — it is a browser session held open for twenty to forty seconds. Budget accordingly and measure per useful row, not per request: one search that yields 87 businesses is far better value than three searches yielding 12 each because the query was too narrow.
On billing specifically: any response Irmu delivers counts as a successful request, including a 404 or a page that came back as an anti-bot challenge. Only failures on our side, or requests to domains we do not cover, are not billed. Design your retry logic knowing that — validate that the payload contains listings before you accept it, and don't retry blindly in a loop.
Staying on the right side of the rules
Collect public business information — name, category, address, phone, rating, review text as published. Do not attempt to identify or enrich individual reviewers, do not touch anything behind a login, and read Google's terms for your specific use case rather than relying on a blog post, this one included, as legal cover.
Irmu's Acceptable Use Policy prohibits circumventing authentication and unlawful personal-data collection, and we would rather lose the request volume than host that. Keep the volume proportionate to what you actually need, cache what you already have, and your crawl stays boring — which is exactly what you want it to be.
Keep reading
Scrape Amazon Product Data with Python
A practical walkthrough for pulling prices, buy box, stock and reviews from Amazon at scale.
EngineeringCloudflare Bypass: What Actually Works in 2026
A technical look at Turnstile, TLS fingerprinting and why most open-source bypasses stopped working.
ComparisonsPlaywright vs Puppeteer for Web Scraping
Two mature browser automation libraries, and how to decide which one your crawler should use.
Start building with Irmu today
200 free credits every month, no card required. Every API, every integration, one key.