Google Maps is one of the richest sources of local business information on the open web. Search a category in a city and you get a ranked list of places, each with a name, address, phone number, category, star rating, and review count. For local lead generation, market research, and competitor analysis, that public listing data is gold: it tells you who operates in an area, how they are rated, and how to reach them.
This guide shows you how to scrape data from Google Maps with Python the reliable way. You build a small, runnable scraper that fetches a rendered Google Maps results page through the Crawling API, parses each place card with BeautifulSoup, collects the core business fields, handles multiple results, and exports the data to JSON and CSV. The whole walkthrough stays scoped to public business listings that anyone can see on the map without an account, and the legality section near the end is not boilerplate, so read it before you point this at any real volume.
What you will build
A Python script that takes a public Google Maps search URL, retrieves the rendered HTML through the Crawling API, and extracts a structured record for every place in the results panel. We will use "restaurants in New York" as the running example, the same target the original version of this tutorial used, and pull these fields from each listing:
- Name the business name as shown on the place card.
- Address the street address or area label for the place.
- Phone the contact number, when the listing exposes one.
- Category the business type label, for example "Italian restaurant".
- Rating the average star rating as a number.
- Review count how many reviews that rating is based on.
By the end you will have those records for every place on the page, written to both maps_results.json and maps_results.csv, ready for a CRM, a spreadsheet, or a database.
Why a plain request fails on Google Maps
If you fire a bare HTTP request at a Google Maps URL from a script, you do not get the tidy list of places you see in your browser. The results panel is built by JavaScript after the initial page loads: the raw HTML that comes back from a simple requests.get is mostly an empty shell, with the actual place cards filled in by scripts that run in a real browser. Parse that shell and you find none of the fields you came for.
On top of that, Google watches closely for automated traffic. Requests that do not look like a real browser, or that arrive in bursts from a datacenter IP, get challenged with a CAPTCHA, fed a consent wall, or blocked outright before they reach any listings. The original version of this tutorial leaned on an internal JSON endpoint to dodge rendering, but those private URLs change without notice and break the moment Google reshuffles them. The durable approach is to render the public page the way a browser does.
So a working Google Maps scraper needs two things in one request: an IP the platform reads as a real visitor, and a browser that runs the page's JavaScript so the place cards actually appear. You can assemble that yourself with a headless browser plus a pool of rotating residential proxies, but keeping that stack healthy is most of the work. The Crawling API folds both into a single call: you send it the URL, it fetches from a trusted residential IP and renders the page, and it returns finished HTML for you to parse.
Prerequisites
You need a few things in place before writing any code. None of them take long.
Basic Python. You should be comfortable writing and running a Python script and installing packages with pip. If BeautifulSoup is new to you, our guide to using BeautifulSoup in Python covers the parsing basics this tutorial assumes.
Python 3.8 or later. Confirm your version with python --version. If you do not have it, install it from python.org or through a distribution like Anaconda.
A Crawlbase account and token. Sign up, open your dashboard, and copy your request token from the account docs page. Your first 1,000 requests are free, no credit card needed. Google Maps needs JavaScript rendering, so use your JavaScript token for these requests. Treat the token like a password: it authenticates your requests, so keep it out of version control.
Set up the project
Create a virtual environment so project dependencies stay isolated, then install the two libraries the scraper needs.
python --version python -m venv maps_env source maps_env/bin/activate pip install requests beautifulsoup4
On Windows, activate the environment with maps_env\Scripts\activate instead of the source line. Two dependencies do the work: requests sends the HTTP call to the Crawling API, and beautifulsoup4 parses the returned HTML so you can pull out individual fields by CSS selector.
Step 1: Grab the search URL
Open Google Maps in your browser and search for "restaurants in New York". The places appear in the scrollable panel on the left, and the URL in your address bar updates to encode the query. Copy that URL. It is the same address you will hand to the scraper, so what you see in the browser is what the API renders.
https://www.google.com/maps/search/restaurants+in+New+York
The /maps/search/ path is the stable, public entry point for a category search, with the query joined by + signs. You can swap in any category and location, for example plumbers+in+Chicago or coffee+shops+in+Austin, and the rest of the scraper stays the same. Avoid the long internal URLs with pb= parameters that the browser sometimes generates; those are private and break without warning.
Step 2: Fetch the rendered page through the Crawling API
Start by getting the HTML. Write a small crawl() function that sends your target URL to the Crawling API with your JavaScript token, asks it to render the page, checks that the underlying page came back with a 200 status, and returns the HTML body. Checking the status before you parse keeps failures loud instead of silent.
import json import requests API_TOKEN = "YOUR_CRAWLBASE_TOKEN" # use your JavaScript token API_ENDPOINT = "https://api.crawlbase.com/" def crawl(url): params = { "token": API_TOKEN, "url": url, "page_wait": 4000, } response = requests.get(API_ENDPOINT, params=params) response.raise_for_status() data = json.loads(response.text) if data["original_status"] != 200: raise Exception(f"Unable to crawl '{url}'") return data["body"] if __name__ == "__main__": url = "https://www.google.com/maps/search/restaurants+in+New+York" html = crawl(url) print(html[:500])
The API returns a JSON envelope, so you load the response with json.loads and read two fields: original_status is the status Google itself returned, and body is the rendered page HTML. The page_wait parameter tells the renderer to wait a few seconds after load so the place cards have time to populate before the HTML is captured. Guarding on original_status means a consent wall or a block surfaces as an exception instead of feeding an empty shell into the parser. Run the script with python crawling.py and you should see real map markup in the first 500 characters, which confirms the fetch and render work before you write a single selector.
That original_status check only reads 200 because the request reached Google Maps as a real visitor and the page was rendered in a real browser first. The Crawling API fetches from a rotating residential IP, runs the page's JavaScript so the place cards actually appear, and hands you finished HTML, so you skip running a headless browser fleet and sourcing a residential proxy pool yourself. Point it at a public Maps search URL on the free tier first.
Step 3: Parse each place card with BeautifulSoup
With rendered HTML in hand, load it into BeautifulSoup and pull each result by its selector. Google Maps wraps every place in the results panel in an article element, and each field sits in a predictable spot inside that card. Inspect the live page in your browser's dev tools (right-click a place card, then Inspect) to confirm the current class and attribute names; the selectors below match the layout at the time of writing.
import re from bs4 import BeautifulSoup def parse_place(card): name_el = card.select_one("div.qBF1Pd") rating_el = card.select_one("span.MW4etd") reviews_el = card.select_one("span.UY7F9") info_rows = card.select("div.W4Efsd > div.W4Efsd") category, address, phone = None, None, None for row in info_rows: text = row.get_text(" ", strip=True) phone_match = re.search(r"(\(?\d[\d\-\s\(\)]{7,}\d)", text) if phone_match and not phone: phone = phone_match.group(1).strip() elif "·" in text and not category: parts = [p.strip() for p in text.split("·")] category = parts[0] address = parts[-1] if len(parts) > 1 else None return { "name": name_el.get_text(strip=True) if name_el else None, "category": category, "address": address, "phone": phone, "rating": float(rating_el.get_text(strip=True)) if rating_el else None, "reviews": parse_reviews(reviews_el), } def parse_reviews(el): if not el: return 0 digits = re.sub(r"[^\d]", "", el.get_text(strip=True)) return int(digits) if digits else 0 def scrape_html(html): soup = BeautifulSoup(html, "html.parser") cards = soup.select("div[role='article']") return [parse_place(card) for card in cards if card.select_one("div.qBF1Pd")]
The selector div[role='article'] matches each place card in the results panel, and the helper reads the named fields from inside it. The business name sits in div.qBF1Pd, the star rating in span.MW4etd, and the review count in span.UY7F9 as text like "(1,234)", which parse_reviews strips down to an integer. The category, address, and phone share the secondary info rows under div.W4Efsd, separated by a middot, so we split on "·" to lift the category and address and use a small regular expression to spot a phone number. Reading each field defensively, with a None fallback, means a listing that omits a phone or a rating still produces a clean record instead of crashing the loop.
Google's class names, like qBF1Pd and MW4etd, are obfuscated build artifacts that change when Google redeploys its front end. Treat the selectors above as a starting template, not a contract. When a field comes back empty for every result, re-inspect a live place card in your browser's dev tools and update the selector. Periodic selector maintenance is normal for any production scraper, not a sign something is broken.
Step 4: Handle multiple results and assemble the script
The results panel holds many places, and the parser already returns one record per card, so handling multiple results is built in. Now wire the fetch and the parse into one runnable script and export the records to both JSON and CSV. The CSV is the format most lead-gen and research workflows want, since it drops straight into a spreadsheet or a CRM import.
import csv import json import re import requests from bs4 import BeautifulSoup API_TOKEN = "YOUR_CRAWLBASE_TOKEN" API_ENDPOINT = "https://api.crawlbase.com/" FIELDS = ["name", "category", "address", "phone", "rating", "reviews"] def crawl(url): params = {"token": API_TOKEN, "url": url, "page_wait": 4000} response = requests.get(API_ENDPOINT, params=params) response.raise_for_status() data = json.loads(response.text) if data["original_status"] != 200: raise Exception(f"Unable to crawl '{url}'") return data["body"] def parse_reviews(el): if not el: return 0 digits = re.sub(r"[^\d]", "", el.get_text(strip=True)) return int(digits) if digits else 0 def parse_place(card): name_el = card.select_one("div.qBF1Pd") rating_el = card.select_one("span.MW4etd") reviews_el = card.select_one("span.UY7F9") info_rows = card.select("div.W4Efsd > div.W4Efsd") category, address, phone = None, None, None for row in info_rows: text = row.get_text(" ", strip=True) phone_match = re.search(r"(\(?\d[\d\-\s\(\)]{7,}\d)", text) if phone_match and not phone: phone = phone_match.group(1).strip() elif "·" in text and not category: parts = [p.strip() for p in text.split("·")] category = parts[0] address = parts[-1] if len(parts) > 1 else None return { "name": name_el.get_text(strip=True) if name_el else None, "category": category, "address": address, "phone": phone, "rating": float(rating_el.get_text(strip=True)) if rating_el else None, "reviews": parse_reviews(reviews_el), } def scrape_html(html): soup = BeautifulSoup(html, "html.parser") cards = soup.select("div[role='article']") return [parse_place(c) for c in cards if c.select_one("div.qBF1Pd")] def main(): url = "https://www.google.com/maps/search/restaurants+in+New+York" html = crawl(url) places = scrape_html(html) with open("maps_results.json", "w", encoding="utf-8") as f: json.dump(places, f, ensure_ascii=False, indent=2) with open("maps_results.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=FIELDS) writer.writeheader() writer.writerows(places) print(f"Saved {len(places)} places to JSON and CSV") if __name__ == "__main__": main()
Run the full script with python main.py. It fetches the rendered results page for "restaurants in New York", extracts a record for each place card, and writes everything to maps_results.json and maps_results.csv. The DictWriter uses the same FIELDS list as both the CSV header and the column order, so the two outputs stay in sync. Swap the query in the URL and the parser handles whatever comes back.
What the output looks like
You get a clean list of place records, each with the six fields, ready to write to JSON, CSV, or a database. Here is a trimmed JSON sample.
[ { "name": "Carmine's Italian Restaurant", "category": "Italian restaurant", "address": "200 W 44th St", "phone": "(212) 221-3800", "rating": 4.5, "reviews": 12873 }, { "name": "Katz's Delicatessen", "category": "Deli", "address": "205 E Houston St", "phone": "(212) 254-2246", "rating": 4.6, "reviews": 48091 } ]
The CSV holds the same data with one place per row and the columns in FIELDS order: name,category,address,phone,rating,reviews. That format imports cleanly into a spreadsheet for de-duplication, or into a CRM as the seed of a local prospect list.
Scaling across cities and queries
One search in one city is a demo; a real job runs the same scraper over many category and location pairs. The URL is the only thing that changes, so build a list of queries and loop over them, parsing each with the same function. The one habit that keeps a long run healthy is pacing, so pause between requests rather than firing them in a tight loop.
import time from urllib.parse import quote_plus queries = [ "restaurants in New York", "coffee shops in Austin", "plumbers in Chicago", ] all_places = [] for q in queries: url = f"https://www.google.com/maps/search/{quote_plus(q)}" html = crawl(url) all_places.extend(scrape_html(html)) time.sleep(3) print(f"Collected {len(all_places)} places across {len(queries)} queries")
Crawlbase serves up to 20 requests per second by default, which is plenty of headroom for a scraper that paces itself; if you genuinely need more, support can raise it. Any 5XX response from the API is free of charge, so retrying a blocked or unavailable URL costs you nothing. The results panel loads a first batch of places and reveals more as you scroll, so a single fetch returns the top results for a query; for deeper coverage, narrow the query by neighborhood rather than trying to scroll an infinite list in one request. For more on this pattern, see our guide to scraping local business listings.
Staying unblocked
Even with a trusted IP and rendering handled, Google watches for scraper-shaped traffic. A few habits keep a run healthy.
- Pace your requests. Hammering Maps in a tight loop is the fastest way to get challenged. Spread requests out and vary your queries instead of paging one term at full speed.
- Lean on rotation. A pool of residential IPs spreads requests across many real-user addresses so no single one trips a limit. The Crawling API handles this for you; if you roll your own stack, this is the part to get right.
- Read the status codes. A run that starts returning consent walls or CAPTCHAs is telling you the current rate or IP tier is no longer enough. Treat that as signal to back off, not noise to ignore.
- Re-inspect when fields go empty. Google changes its markup periodically. If place cards stop parsing, open a live page in dev tools and update the selectors.
For the broader playbook, see how to scrape websites without getting blocked. Because the Maps panel is JavaScript-rendered, our guide on crawling JavaScript websites explains why rendering matters and how to turn it on. If your project also touches the regular search results page, our guide to scraping Google search pages covers that surface.
Is it legal to scrape Google Maps?
Whether scraping Google Maps is allowed depends on Google's terms of service, your jurisdiction, and what you do with the data. Google's terms place limits on automated access to its products, so scraping can run against those terms regardless of how careful your tooling is. None of the code here changes that; it just makes the technical part work. Read Google's terms and its robots.txt, and treat both as the boundary for what you collect.
A few lines worth holding to. Collect only public business listing data: the names, addresses, phone numbers, categories, ratings, and review counts that any visitor can see on the map without an account. That is business information published so customers can find a place. Do not scrape the contents of individual reviews, reviewer names or profiles, or any other personal data, and do not redistribute photos or other copyrighted media pulled from listings. Keep your request volume low enough that you are not straining Google's servers, and pace your crawl rather than running it flat out.
If you need place data at scale or with a contractual guarantee, Google offers the official Places API, which is the sanctioned way to query business details and is the right path for anything beyond light, public research. This guide is deliberately scoped to public listing fields visible on the results panel because that is the line that keeps the work defensible. Public business data only. If your project needs more than that, the official API or a data agreement is the correct path, not a cleverer scraper.
Key takeaways
- The panel is JavaScript-rendered. A plain request gets an empty shell, so you need a real browser to run the page before the place cards exist to parse.
- The Crawling API fetches and renders. Send it the Maps URL with your JavaScript token, it rotates residential IPs and runs the page's scripts, and returns finished HTML.
-
BeautifulSoup pulls six fields per card. Select each
div[role='article'], then read name, category, address, phone, rating, and review count, and expect the obfuscated class names to drift. - Export to JSON and CSV. One record per place feeds straight into a spreadsheet or CRM for local lead gen and market research.
- Stay on public data. Respect Google's ToS and robots.txt, skip reviews and personal data, and prefer the official Places API for anything at scale.
Frequently Asked Questions (FAQs)
Why does a plain request return no places from Google Maps?
The results panel is built by JavaScript after the page loads, so a bare requests.get comes back with a mostly empty shell and none of the place cards you see in your browser. Google also challenges traffic that does not look like a real browser. Fetching through the Crawling API with rendering enabled runs the page's scripts from a trusted residential IP, so the place cards appear in the HTML you get back.
Can I scrape Google Maps with Python?
Yes. With requests and BeautifulSoup you can fetch a rendered results page and pull out the name, address, phone, category, rating, and review count for each place. The Crawling API acts as the bridge that gets your request to Google from a trusted IP and renders the JavaScript, so requests are processed smoothly instead of being blocked. For a broader Python primer, see our guide on scraping websites with Python.
What business fields can I extract from a Google Maps listing?
This tutorial pulls six fields from each place card: the business name, category, address, phone number, star rating, and review count. Those are public listing fields any visitor can see on the map. Stay within that public data and avoid review contents, reviewer profiles, or anything else that is personal.
Do I need JavaScript rendering to scrape Google Maps?
Yes. Unlike a plain search results page, the Maps results panel does not exist in the initial HTML; it is rendered by scripts in the browser. Use your Crawlbase JavaScript token so the Crawling API renders the page before capturing it. Our guide to scraping JavaScript pages with Python covers when rendering is necessary.
How do I scrape Google Maps across many cities?
Build a list of category and location queries, turn each into a /maps/search/ URL, and loop over them, fetching each through the Crawling API and parsing it with the same function. Pause a few seconds between requests so you are pacing the crawl rather than hammering it, and collect the records into one combined list before you export.
Is there an official alternative to scraping Google Maps?
Yes. Google's official Places API is the sanctioned way to query business details such as name, address, phone, rating, and reviews, with a contract and quotas behind it. For anything beyond light public research, or for production volume, the official API is the right path; scraping the public panel is best kept to small, respectful jobs on data anyone can see on the map.
Crawl any site at scale, without fighting infrastructure.
Crawlbase handles proxies, fingerprints, and CAPTCHAs so your team ships data pipelines instead of maintaining crawl plumbing. 1,000 requests free, no card required.
