Byteful joins The Ethical Web Data Collection Initiative
BlogHow to Scrape Google Maps with Python

How to Scrape Google Maps with Python

Google Maps Scraper Guide.png

If you tried to scrape Google Maps with requests and BeautifulSoup, you might have gotten a page with no listings. That's not a bug in your code. Google Maps is a JavaScript app, so the first HTML response is an empty shell, and listings arrive only after a browser runs the app.

The harder part is handling the scrollable results panel, avoiding selectors Google can regenerate, and getting beyond what one search returns.

This guide provides Playwright code that can process all three, a grid search that goes beyond the results ceiling, and an honest comparison of the cost of buying versus processing via Playwright.

What data you can scrape from Google Maps

The fields in the results list are free, and the ones that require a click into the business cost a full page load each. The second group turns a short scrape into several minutes, so know which group each field is in before you write any code.

  • Business name: Located in the aria-label of the listing link, which is a stable location to read it. Free from the feed.
  • Place identifier: A hex string that is included in the listing URL, such as 0x880e2ca...:0x3c98... The value stayed consistent across repeated searches and overlapping tiles in our runs, which makes it a good dedupe key. Two caveats: It's not the official Place ID returned by the Places API, and we haven't tried to determine how long it lasts.
  • Star rating and review count: Both live in one aria-label, formatted like "4.5 stars 1,234 Reviews", so a single regex gets both. A business at 4.8 stars with 6 reviews is a completely different lead from one at 4.2 with 900. Free from the feed, with a timing caveat.
  • Website: This is the highest-value field to obtain leads because it can lead to an email address. When present, it is the only card link not pointing to google.com. The problem is, it's often not there. In a test of seven categories at the same coordinates, websites appeared on 10 of 10 sampled lawyer listings, 7 of 10 plumber listings, and none of the sampled coffee shops, dentists, hotels, gyms, or hardware stores.
  • Coordinates: Latitude and longitude, embedded in the listing URL, not on the card. These are a must-have for mapping or distance math, and they're free except for a regular expression.
  • Phone number: The most common reason people pay the detail-pane tax. One additional page load each, which makes 120 businesses take roughly 4 to 6 minutes instead of 30 seconds. Only worth using for a call list; otherwise, skip.
  • Opening hours: Also, behind the detail pane, the week only appears after you click on the hours control.

Why Browser Automation is Required

The listing data is rendered after JavaScript executes, so plain HTTP requests come back empty. Request the search URL with a browser user agent, and you'll get a clean 200, roughly 200 KB of HTML, and no listings. Only Google's message to enable JavaScript.

For less brittle selectors, use role/aria-label attributes instead of the generated CSS class names by Google.

In our tests, accessibility attributes performed better because role="feed" and aria-label describe what an element does rather than how it looks. That makes them less likely to break when Google restyles the page, though nothing is permanent; if they start returning nulls, inspect the attributes again.

Prerequisites

This requires Python 3.10 or above, and a virtual environment:

python -m venv .venv
source .venv/bin/activate            # Windows: .venv\Scripts\activate
pip install playwright
playwright install chromium

With Playwright installed, start by confirming Chromium launches correctly.

Step 1. Launch Chromium with a realistic context

Start with a smoke test that opens Maps in a realistic browser context:

import asyncio
from playwright.async_api import async_playwright
 
CONTEXT_OPTIONS = {
    "locale": "en-US",
    "timezone_id": "America/Chicago",
    "viewport": {"width": 1440, "height": 900},
}
 
async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=False)
        context = await browser.new_context(**CONTEXT_OPTIONS)
        page = await context.new_page()
        await page.goto("https://www.google.com/maps")
        await page.wait_for_timeout(3000)
        await browser.close()
 
asyncio.run(main())

Run this with headless=False during development so you can see consent screens or failed loads.

The three context options matter. locale keeps labels such as "stars" and "Reviews" in English, timezone_id should match the requested location, and a larger viewport shows more listings per scroll.

Step 2. Build the Google Maps search URL

Skip the search box entirely and construct the URL:

from urllib.parse import quote_plus
 
def search_url(query, lat, lng, zoom=14):
    return (
        f"https://www.google.com/maps/search/{quote_plus(query)}"
        f"/@{lat},{lng},{zoom}z?hl=en"
        )

quote_plus converts "coffee shops" into "coffee+shops" and safely handles spaces and punctuation in the search term. ?hl=en will force English replies from the "coffee shops" regardless of which country the request comes from.

Pass wait_until="domcontentloaded" when you navigate. It returns as soon as the HTML is parsed, instead of the default load, which waits for the full page. Either way, the listings aren't there yet because they render after JavaScript runs, so navigation state is the wrong thing to wait for. Step 3 waits for the results feed instead.

In our measurements, at zoom level 14, the visible map spanned about 10.5 km across a 1,440 px window, and at zoom level 15 we saw an area of about 5.3 km wide in the same window. The higher the numbers, the closer you zoom in; the lower the numbers, the farther you zoom out. This matters when choosing the grid spacing.

Step 3. Wait for the results feed, and catch a failed load

A loading page is not a working page. Google will return a normal 200 in response to a consent screen, or a “before you continue” wall; the wait and the check should be in the same function:

from playwright.async_api import (
    TimeoutError as PlaywrightTimeout,
    Error as PlaywrightError,
)
 
async def wait_for_feed(page):
    feed = page.locator('div[role="feed"]')
    try:
        await feed.wait_for(state="visible", timeout=30_000)
    except PlaywrightTimeout:
        # Work out WHERE we landed before reporting a generic failure
        if "consent.google.com" in page.url or "/sorry/" in page.url:
                raise RuntimeError(f"Blocked or redirected to consent: {page.url}")
        raise RuntimeError("No results feed. Likely a CAPTCHA or an empty search.")
        return feed

Don't test for the feed with an instant existence check. In our measurements, it appeared about 130 milliseconds after domcontentloaded, so checking immediately would falsely report failure. That's why the function uses wait_for with a timeout. Playwright keeps polling until role="feed" becomes visible. The screenshot below shows that element in the results panel.
Google Maps results page showing the Caffe Umbria listing highlighted alongside the matching results feed and listing link in Chrome DevTools

Step 4. Scroll the feed until all listings load

We threw seven listings in the first time at our coffee shops, and more had been added when we scrolled through the panel. Other queries started with more. The element, not the page, is scrolled.

import random, re
 
END_OF_LIST = re.compile(r"reached the end of the list", re.I)
 
async def scroll_feed(page, max_rounds=40):
    feed = page.locator('div[role="feed"]')
    links = page.locator('div[role="feed"] a[href*="/maps/place/"]')
    last_count, stagnant = 0, 0
 
    for _ in range(max_rounds):
        await feed.evaluate("el => el.scrollTo(0, el.scrollHeight)")
        await page.wait_for_timeout(random.randint(900, 1800))
 
        if await page.get_by_text(END_OF_LIST).count() > 0:
                break
 
        count = await links.count()
        if count == last_count:
                stagnant += 1
                if stagnant >= 3:
                    break
        else:
                stagnant, last_count = 0, count
 
        return await links.count()

Unlike page.mouse.wheel(), feed.evaluate("el => el.scrollTo(0, el.scrollHeight)") targets the results panel directly. The 900–1800 ms wait gives each batch time to arrive, while the loop stops after three stagnant rounds or when END_OF_LIST matches the end-of-list message.

In our tests, the feed never went over 120 listings, and you only get close to this if businesses actually exist. Six live runs landed at 54, 74, 111, 117, and 120 twice.

The screenshot below shows the 74-listing run.
PyCharm showing the scroll test script output with listing counts increasing and a final total of 74 listings

Step 5. Extract the fields from each result card

FID         = re.compile(r"(0x[0-9a-f]+:0x[0-9a-f]+)")
COORDS  = re.compile(r"!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)")
RATING  = re.compile(r"([\d.]+)\s+stars?", re.I)
REVIEWS = re.compile(r"([\d,]+)\s+reviews?", re.I)
 
async def extract_cards(page):
    rows = []
    cards = page.locator('div[role="feed"] > div:has(a[href*="/maps/place/"])')
 
    for i in range(await cards.count()):
        card = cards.nth(i)
        link = card.locator('a[href*="/maps/place/"]').first
        href = await link.get_attribute("href") or ""
 
        fid = FID.search(href)
        coords = COORDS.search(href)
 
        # One attribute holds both rating and review count:
        # "4.5 stars 1,234 Reviews"
        stars = card.locator('span[role="img"][aria-label*="stars"]').first
        label = ""
        if await stars.count():
                for _ in range(10):                     # poll up to ~1.5s
                    label = await stars.get_attribute("aria-label") or ""
                    if "eview" in label:
                        break
                    await page.wait_for_timeout(150)
        rating, reviews = RATING.search(label), REVIEWS.search(label)
 
        # The business website is the only outbound non-Google link in a card
        website = None
        anchors = card.locator('a[href^="http"]')
        for j in range(await anchors.count()):
                u = await anchors.nth(j).get_attribute("href")
                if u and "google.com" not in u:
                    website = u
                    break
 
        rows.append({
                "name": await link.get_attribute("aria-label"),
                "fid": fid.group(1) if fid else None,
                "lat": coords.group(1) if coords else None,
                "lng": coords.group(2) if coords else None,
                "rating": rating.group(1) if rating else None,
                "reviews": reviews.group(1).replace(",", "") if reviews else None,
                "website": website,
                "place_url": href.split("?")[0],
                "card_text": (await card.inner_text()).replace("\n", " | "),
        })
 
        return rows

A few details in the extraction logic are worth explaining.

The selector above selects direct children of the feed that contain a place link, but filters out the wrappers and spacers that are not div children of the feed, without you even knowing what they look like. The direct-child part does work: drop the > and the count doubles, since :has() begins to match wrappers as well.

The website trick is effective because if there is a website link on the card, there's only one link, and it leads to the business. This was more useful in testing than the other option of targeting a specific class or data attribute, and it doesn't cost you anything.

Some categories have no website button, so this field can be empty.
Google Maps lawyer results showing Website buttons beside coffee shop results without Website buttons

We keep the raw card_text on purpose. The category, street line, and open/closed status are plain text with no stable attributes on the card, so any selector we published for them would be a guess with an expiry date. If Google changes the layout, you write a new regex against card_text instead of hunting for a new class name.
PyCharm showing 120 extracted Google Maps listings with business details including coordinates, ratings, reviews, and card text

The polling loop matters because the aria-label can initially contain only "4.6 stars" before the review count arrives. Without polling, review-count completeness averaged about 83% in our runs; with polling, it ranged from 79% to 100% across four runs. Even longer waits didn't guarantee every count, so build for missing values.

Phone, address, and hours from the detail pane

A page load per business is required for those fields. They reside in the back, behind data-item-id, which exposes a meaningful field name and not a generated styling class, making it a better selector to target. The website is different from the other three, as the href is what you read instead of its label.

DETAIL_FIELDS = {
    "address":   'button[data-item-id="address"]',
    "phone":     'button[data-item-id^="phone:tel:"]',
    "website":   'a[data-item-id="authority"]',
    "plus_code": 'button[data-item-id="oloc"]',
}
 
async def scrape_detail(page, place_url):
    await page.goto(place_url + "?hl=en", wait_until="domcontentloaded")
    await page.wait_for_selector("h1", timeout=15_000)
 
    out = {}
    for key, selector in DETAIL_FIELDS.items():
        el = page.locator(selector).first
        if not await el.count():
                continue
        if key == "website":
                # This one is an anchor. Take the destination, not the label,
                # which shows a trimmed display domain rather than the full URL.
                out[key] = await el.get_attribute("href")
        else:
                # Labels read "Phone: +1 312-555-0100", so split off the prefix
                label = await el.get_attribute("aria-label") or ""
                out[key] = label.split(":", 1)[-1].strip()
 
    week_btn = page.locator('[aria-label="Show open hours for the week"]').first
    if await week_btn.count():
        await week_btn.click()
        await page.wait_for_timeout(800)
        rows = page.locator('tr:has(td[role="text"])')
        schedule = {}
        for i in range(await rows.count()):
                row = rows.nth(i)
                day = (await row.locator("td").first.inner_text()).strip()
                schedule[day] = await row.locator('td[role="text"]').get_attribute("aria-label")
        out["hours"] = schedule
        return out

The obvious shortcut for opening hours doesn't work. The collapsed control has an aria-label, and you would expect it to span an entire week, as any screen reader would.

The one word that was its value in every business we tested was “Hours”. Clicking to the week view does work, which results in a table with one row per day and a clean label, so you read “7 am to 5 pm”, rather than splitting a string.

If the week table doesn't appear after the first click, don't click again. The control is a toggle, so a second click closes whatever the first opened. We hit this once in nine runs, and re-clicking five times never recovered it. Reload the page and start over.
PyCharm showing extracted Google Maps address, phone, website, plus code, and seven-day business hours

Detail-page enrichment is slower. Every business requires a full page load, so 1,000 businesses take about 33-50 minutes in total. There's a reason why scrape_detail is not in the main pipeline: call it, after deduping, on a shortlist.

Step 6. Break the 120-result cap with grid search

The fix is based on the coordinate parameter obtained in Step 2. The cap is per search and not per area, so if you search using 81 different centers on the map, you'll find 81 potential result sets that overlap along their edges. Combine them, remove the repeats, and you get more than one search.

from math import cos, radians
 
def build_grid(lat, lng, radius_km, step_km):
    lat_step = step_km / 111.0
    lng_step = step_km / (111.0 * cos(radians(lat)))
    rings = int(radius_km / step_km)
    return [
        (round(lat + i * lat_step, 6), round(lng + j * lng_step, 6))
        for i in range(-rings, rings + 1)
        for j in range(-rings, rings + 1)
        ]

The numbers are not as complex as they may seem. One degree of latitude is approximately 111 km everywhere, while one degree of longitude decreases as you move away from the equator, so divide by the cosine of the latitude. Skip that, and your tiles can stretch and leave gaps.

The first naming note is that build_grid(41.8781, -87.6298, radius_km=12, step_km=3) returns exactly 81 tiles, but the corners are not located at 12 km and instead are located at about 17 km. Read as a half-width.
PyCharm showing an 81-tile Google Maps grid with the first ten generated coordinate pairs

Match your tile size to your zoom level, and make them overlap. Our measured viewport was approximately 10.5 km across at zoom 14, the default in Step 2, which means that a 3 km step will overlap by approximately 70%. At zoom 15, the viewport was 5.3 km, and thus the overlap was closer to 45%. Both settings produce overlapping nominal viewports, so the grid geometry itself doesn't leave any gaps.

It's not a waste of that overlap. It prevents a business from 'slipping through the cracks' when found in two searches. The 454 rows returned at zoom 14 were deduped to 203 rows, in 4 adjacent tiles.
Google Maps showing the same Chicago area at zoom levels 14 and 15 with 500 m and 200 m scale bars visible

Use the right tile size for the density of the area. If there are 100 or more listings on a single tile, try testing a smaller step and compare. One rule to apply is to cut the step in half for any tile with 100 or higher. The size to which you need to shrink will be determined by the area and category, and the only way to know is to observe the counts.

Step 7. Add residential proxies for distributed, location-consistent requests

Grid search increases request volume, and larger runs may encounter consent pages, CAPTCHAs, or an empty feed. The last is the dirty one, as your script continues to run and writes empty rows. We have not tried to determine at what point that threshold is, and it is not set in stone.

This isn't the job of a free proxy list. Those addresses are highly reused; they are usually marked, and these machines are unknown to you.

Residential proxies do two things instead. They forward requests over numerous addresses instead of focusing them on a single one, and they enable you to match request location to the place you're scraping. Neither ensures continuous access. That's location, which, as Google determines local results partly by distance, can make a difference in what it returns when you pull some Chicago data through Frankfurt.

Google's own documentation says local results are based partly on distance, which is why matching the proxy city to the area you are querying keeps results closer to what a local sees.

Start by loading the proxy credentials from environment variables:

import os
 
# Host and port come from your dashboard, not from this page. Providers
# issue a different endpoint per configuration, so hardcoding one here
# would send half of you to an address that does not exist.
PROXY_SERVER = f"http://{os.environ['PROXY_HOST']}:{os.environ['PROXY_PORT']}"
PROXY_USER = os.environ["PROXY_USERNAME"]
PROXY_PASS = os.environ["PROXY_PASSWORD"]
 
def proxy_for_tile(index, attempt, country="us", city=None):
    username = f"{PROXY_USER}_c_{country}"
    if city:
        username += f"_city_{city}"
    # Session ID carries the attempt number, so a retry opens a NEW
    # sticky session rather than asking for the same exit node again.
    username += f"_s_tile{index}try{attempt}"
        return {"server": PROXY_SERVER, "username": username, "password": PROXY_PASS}

Put all four in the environment variables from your dashboard. 1 GB of free residential data is enough to validate the setup before a larger run. Before you point 81 tiles at this, prove the username actually does what you think.

Byteful's proxy tester takes a proxy string in host:port:username:password form:

YOUR_HOST:YOUR_PORT:YOUR_USERNAME_c_us_city_chicago_s_test1:YOUR_PASSWORD

Fill in the 4 YOUR_ placeholders from your dashboard without changing the modifiers. Run it twice using _s_test1, then once using _s_test2. The first two are to remain on the same address as long as the node exists, and the third is an independent session and can vary.

Check the city on every run, not only on the initial run. We tested a modifier that the provider does not document, and it was able to connect and report the correct country but the wrong city, 700 miles from the requested city.
Byteful Proxy Tester showing a successful residential proxy connection with Chicago, United States geolocation

Next, reduce your bandwidth, since residential proxies are priced on the gigabyte and Maps uses a ton of images.

BLOCK = re.compile(r"\.(png|jpe?g|webp|gif|svg|mp4|woff2?)(\?|$)"
                   r"|lh3\.googleusercontent\.com"   # business photos
                   r"|/maps/vt/")                        # map tiles

Pay attention to the pattern that has to match. Maps use /maps/vt/ for their tiles and lh3.googleusercontent.com for their photos, and no extensions are present at the end of these URLs. Most examples include a pattern made up of extensions only, which will prevent webfonts and loader icons from showing up, and all the tiles and photos will fly through. The whole point of the rule is to explicitly name the two hosts.

Check the request list to confirm the rule is working. Tile and photo requests should appear as aborted rather than transferred. In our testing, blocking them did not affect extraction: the blocked scrape returned the same row count and field-fill results as the control.

The map itself may appear blank because its tiles are being blocked; that doesn't mean extraction failed.

This will tie the pieces in Steps 2 to 5 together. Every tile is given its own context, its own session, and its own retry budget:

async def scrape_tile(browser, index, attempt, query, lat, lng, loc):
    context = await browser.new_context(
        proxy=proxy_for_tile(index, attempt, loc["country"], loc["city"]),
        **{**CONTEXT_OPTIONS, "timezone_id": loc["timezone"]},
    )
    await context.route(BLOCK, lambda route: route.abort())
    try:
        page = await context.new_page()
        await page.goto(search_url(query, lat, lng), wait_until="domcontentloaded")
        await wait_for_feed(page)
        await scroll_feed(page)
        return await extract_cards(page)
    finally:
        await context.close()
 
async def run_tiles(p, query, tiles, loc):
    # Launch once, then give every tile its own context and its own session
    browser = await p.chromium.launch()
    rows = []
    for i, (lat, lng) in enumerate(tiles):
        for attempt in range(3):
                try:
                    tile_rows = await scrape_tile(browser, i, attempt, query, lat, lng, loc)
                    print(f"tile {i}: {len(tile_rows)} rows")
                    rows += tile_rows
                    break
                except (RuntimeError, PlaywrightError) as e:
                    print(f"tile {i} attempt {attempt + 1} failed: {e}")
                    await asyncio.sleep(5 * (attempt + 1))   # 5s, then 10s, then 15s
    await browser.close()
        return rows

The attempt number sits inside the s session ID, so each retry starts a distinct sticky session while keeping one session ID for that attempt.

The browser is started without a proxy argument, as today's Playwright network documentation demonstrates for context-level proxies. A known issue that applies to Chromium on Windows: Chromium has sometimes required a global proxy at launch time, which is why "Browser needs to be launched with the global proxy" is a common error. If you hit that, just use proxy={"server": "http://per-context"} as a placeholder that will be overwritten by all contexts.

A sticky session asks for one exit node rather than guaranteeing it, since the address changes if that residential device drops offline. During our testing, it was a session that had been requested back-to-back and then moved on several minutes later, so exercise good judgment about continuity and size tiles to complete quickly.

The city_chicago modifier is used to set the location to the request. The tile-to-location mapping above is a one-liner because Byteful's residential pool has country, state, city, zip, and ASN targeting options through these username modifiers.

Step 8. Dedupe and export to CSV

Overlapping tiles create duplicates by design, so dedupe them before export.

import csv
 
def dedupe(rows):
    seen = {}
    for row in rows:
        key = row["fid"] or (row["name"], row["lat"], row["lng"])
        if key not in seen:
                seen[key] = row
    return list(seen.values())
 
def write_csv(rows, path="listings.csv"):
    fields = ["name", "fid", "lat", "lng", "rating",
                  "reviews", "website", "place_url", "card_text"]
    with open(path, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fields)
        w.writeheader()
        w.writerows(rows)

Our tests showed that the fid (hex identifier from the listing URL) remains the same when the search is repeated, so this is where you should dedupe.

Avoid deduping on the business name. A city can have a lot of locations in one chain, and name-matching merges them into a single location in the city, while destroying all of the chain data without any warning. The name-plus-coordinates fallback only runs if a URL does not parse; it did not run once in a 454-row test run.
Google Sheets showing exported Google Maps listings with names, FIDs, coordinates, ratings, reviews, and website fields

Step 9. Run the full script and check your results

Steps 1-8 now provide the pieces. Add the CLI entry point:

import argparse
 
def parse_args():
    ap = argparse.ArgumentParser(
        description="Scrape Google Maps listings across a coordinate grid")
    ap.add_argument("--query", required=True)
    ap.add_argument("--lat", type=float, required=True)
    ap.add_argument("--lng", type=float, required=True)
    ap.add_argument("--radius-km", type=float, default=12)
    ap.add_argument("--step-km", type=float, default=3)
    ap.add_argument("--out", default="listings.csv")
    # Location settings must match the coordinates you are scraping
    ap.add_argument("--country", default="us", help="proxy country code")
    ap.add_argument("--city", default=None, help="proxy city, e.g. chicago")
    ap.add_argument("--timezone", default="America/Chicago")
    return ap.parse_args()
 
async def main():
    args = parse_args()
    tiles = build_grid(args.lat, args.lng, args.radius_km, args.step_km)
    loc = {"country": args.country, "city": args.city, "timezone": args.timezone}
    print(f"{len(tiles)} tiles to scrape")
 
    async with async_playwright() as p:
        rows = await run_tiles(p, args.query, tiles, loc)
 
    unique = dedupe(rows)
    write_csv(unique, args.out)
    print(f"{len(rows)} rows scraped, {len(unique)} after dedupe -> {args.out}")
 
if __name__ == "__main__":
    asyncio.run(main())

This final main() assembles the earlier steps. Save it all as gmaps_scraper.py and run it:

export PROXY_HOST="YOUR_HOST"
export PROXY_PORT="YOUR_PORT"
export PROXY_USERNAME="YOUR_USERNAME"
export PROXY_PASSWORD="YOUR_PASSWORD"
 
python gmaps_scraper.py --query "coffee shops" --lat 41.8781 --lng -87.6298 \
                            --radius-km 12 --step-km 3 \
                            --country us --city chicago --timezone America/Chicago

Before running it, fill in all four YOUR_ placeholders from your dashboard. In Step 7, all of them are read at import time; if they aren't present, then you will receive a KeyError before you're able to open the browser.

On Windows PowerShell, these four lines are changed to

$env:PROXY_HOST="YOUR_HOST", $env:PROXY_PORT="YOUR_PORT", $env:PROXY_USERNAME="YOUR_USERNAME", and $env:PROXY_PASSWORD="YOUR_PASSWORD".

The above is an example of Chicago throughout. If you change the coordinates, then alter --country, --city, and --timezone to reflect this change as well; otherwise, you'll be using a Chicago address to query Los Angeles, on Chicago time.
PyCharm showing a four-tile Google Maps scrape with 428 rows reduced to 192 after deduplication and saved as CSV

Then review the output, as a scraper that silently fails is worse than a scraper that crashes. Four checks will catch almost everything:

  • Compare the number of rows from a manual search: Search the same term over the same area by yourself. Otherwise, if the grid returned fewer records in comparison with an unassisted search, your tiles are broken, or your scroll is exiting early.
  • Review the fill rate for each column: Of our 508 rows, 508 ratings were returned. Review counts can be less complete; the label may be rendered asynchronously, even with Step 5 polling, and website fill can legitimately be zero. Use your own clean run percentage. Don't assume that any percentage is a universal one. If ratings fall in close proximity to zero, examine the label format, selector, and page load.
  • Search for tiles that returned 0: A zero-result tile in an otherwise dense run is a failure signal worth checking. The print in run_tiles per-tile is there for you to see.
  • Check the first five rows: Open five place_url values and make sure that name, rating, and the number of reviews are correct. This is a case that all of the automated checks will miss: correctly formatted data pulled from the wrong element.

SERP API vs building it yourself

DIY isn't always the best fit, so compare it with the alternatives:

DIY Playwright + residential proxiesScraping / SERP APIGoogle Places APINo-code tool
Setup effort (rough)Highest. A day or twoLowLowLowest
MaintenanceYours, whenever Maps changesVendor'sNear zero, versioned endpointsVendor's
Cost per 1,000 listingsBandwidth only, cents at retail rates, plus your timeVaries by provider and planPotentially $0 within the monthly free call allowance; paid usage depends on field tier and request countVaries by plan
ReviewsWhatever the page shows, but slowVaries by provider and plan5 per place, hard limitVaries by plan
Volume ceilingNo plan cap; limited by infrastructure and target behaviorYour plan tier60 results per query, hardPlan caps, often no API
Who handles layout changesYouVendorGoogleVendor

Google's figures come from the Maps Platform pricing tables. The other two columns are qualitative by intent; rates will differ from provider to provider and from what is considered to be a billable unit.

The pricing of Places API is tiered. The free monthly allowance is first, followed by 5,000 Text Search calls as a Pro user, and 1,000 in each Enterprise tier. Google charges at the top tier of any field you request, which means ratings move you to Enterprise, and reviews to Enterprise plus Atmosphere.

Past the allowance, list prices are $32, $35, and $40 per 1,000 calls. The Text Search call returns no more than 20 results per page, which means that 1,000 results would require at least 50 calls to get all of them, which is within the limits of the Pro call and could potentially be free.

Reviews are where the API becomes limiting. It returns review text, but the Place resource caps it at five per place with no pagination. The API also caps at 60 results per query, half of the amount that the Maps interface provides.

Create it yourself for infrequent use in the low thousands, for any field that is not exposed by APIs, or if you are already running Playwright. If it's a recurring event, purchase an API for it, as you don't want to find yourself in the position of discovering a change in Google's layout at 9 AM on a Monday. When you need Google-approved data, and 60 results are sufficient, you need the Places API, not a lead list.

FAQs

Google Maps Scraping FAQs

FAQs
cookies
Use Cookies
This website uses cookies to enhance user experience and to analyze performance and traffic on our website.
Explore more