🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Guides

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.

S SparkProxy 1 21 min read
Share
How to Scrape Google Maps Data (Listings and Reviews)

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.

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.

FieldWhere it livesHow to get itNotes
Business nameSearch card + place page`a[href*="/maps/place/"]` `aria-label`; `h1` on place pageThe aria-label is the most stable name source
Place URLSearch card`a[href*="/maps/place/"]` `href`Your stable per-business identifier
CoordinatesPlace URLregex `!3d!4d` in the hrefAlready in the URL, no geocoding call needed
RatingSearch card + place`span[role="img"][aria-label*="star"]`e.g. "4.5 stars 1,240 reviews"
Review countSearch card + placesame star `aria-label`Parse the number out of the label
CategorySearch card + placecard text / category buttone.g. "Coffee shop"
AddressPlace page`button[data-item-id="address"]` `aria-label`Prefixed "Address: ..."
PhonePlace page`button[data-item-id^="phone:tel:"]` `aria-label`Prefixed "Phone: ..."
WebsitePlace page`a[data-item-id="authority"]` `aria-label`The business's outbound URL
Opening hoursPlace pagehours table rows / `button[data-item-id="oh"]`Needs JS render, may need a click to expand
Plus codePlace 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!4d 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.

Free trial

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.

SignalWhat you'll seeHow to handle it
"Unusual traffic" pagereCAPTCHA 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 feedJS didn't run, or feed wasn't scrolled`render_js=true` plus scroll the feed container
~120-result capFeed ends far short of every businessTile the map by a lat/lng grid
Selector returns nothingClass name rotatedAnchor 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 like US and you skip the consent.google.com interstitial 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.

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.

Limited-time · 50% off

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

Claim Discount

About the Author

SparkProxy Technical Team, the SparkProxy engineering group builds and maintains global datacenter and residential proxy infrastructure plus a managed Scraping API. This guide reflects patterns tested against Google Maps in 2026 using Python 3.11+, requests 2.32+, and selectolax 0.3+. Google obfuscates and rotates its Maps class names often, so the durable selectors here favor aria-label, role, and data-item-id attributes; verify any class-based selector before a long run and treat it as a value that can change.

Citations: hiQ Labs v. LinkedIn, 9th Cir. 2022 · Google Places API · SparkProxy Scraping API docs

Keep reading

Related articles

How to Scrape Amazon Product Data at Scale

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.

SparkProxy·Guides