You can scrape public Google Maps places and reviews with one Python file and no login: our free script toolzerhub/google-maps-scraper on GitHub. It is 375 lines and uses one package, curl_cffi. On 2026-10-17 it read all 316 reviews of a Seattle cafe in 17.3 seconds from our datacenter server with no proxy. One map view returns at most 200 places, fewer after repeats.
How do you set up the Google Maps scraper?
Clone the repo, install curl_cffi and run search. We ran everything on 2026-10-17 with Python 3.12.13 and curl_cffi 0.16.3, at commit 11ce631 (the script is unchanged in e9c5f1d, which edited the README). Times are from our datacenter server with no proxy, a Hostinger IP in Germany, unless a line says residential: a proxy with a US exit.
git clone https://github.com/toolzerhub/google-maps-scraper.git
cd google-maps-scraper
pip install -r requirements.txt
python google_maps_scraper.py search "coffee shops in Seattle" --limit 20
No browser runs. The script sends Chrome-like requests to the addresses the Maps page uses: google.com/search?tbm=map for places and google.com/maps/preview/place for one place. Each carries a pb parameter, a long packed string. The script holds a copy and changes only the query, the result offset and the map view.
pb = SEARCH_PB
# The first !1s in this pb is the search text slot (it holds Google's session id).
pb = re.sub(r"!1s[^!]*", lambda _: "!1s" + query.replace("!", " "), pb, count=1)
pb = re.sub(r"!8i\d+", f"!8i{offset}", pb, count=1)
# The map view: centre (!2d longitude, !3d latitude) and width in metres (!1d).
pb = re.sub(r"!2d-?[\d.]+", f"!2d{lng}", pb, count=1)
pb = re.sub(r"!3d-?[\d.]+", f"!3d{lat}", pb, count=1)
pb = re.sub(r"!1d[\d.]+", f"!1d{span}", pb, count=1)
params = {"tbm": "map", "authuser": "0", "hl": "en", "gl": "us", "q": query, "pb": pb}
doc = self.json(BASE + "/search?" + urlencode(params, quote_via=quote))
Google answers with unnamed arrays, about 260 slots per business, that the script maps to named fields. Every command takes -o file (JSON, or CSV for a .csv name) and --proxy URL.
How do you search Google Maps for businesses?
Run search with a query and --limit; it returns each place's name, address, phone, website, rating, review count, opening hours, coordinates and categories, 20 rows per request. The command above took 0.8 seconds from the datacenter server and 1.3 through the proxy, and returned 20 places. Trimmed output for one of them:
{
"cid": "0x54906b88c68b6ae9:0x675e801f4206257f",
"name": "Overcast Coffee",
"address": "1517 12th Ave Ste 100, Seattle, WA 98122",
"phone": "(206) 588-2621",
"website": "http://www.overcastcoffee.co/",
"rating": 4.7,
"review_count": 316,
"open_status": "Open · Closes 5 PM"
}
The cid is Google's id for the place. Put the area in the query ("dentists in Chicago") or pass --lat, --lng and --span, the view's width in metres. --limit 100 -o dentists.csv wrote 100 rows in 7.2 seconds (12.6 residential).
How many places can one Google Maps search return?
At most 200 per map view: Google returns 20 rows a page for 10 pages, then nothing, and pages repeat some places. Our datacenter grid run got 163 to 199 per view and the proxy run 127 to 172. The script asks for offsets 0 to 180 in steps of 20:
MAX_OFFSET = 180 # place search is empty from offset 200 (about 182 distinct places)
--limit 300 on "coffee shops in Seattle" returned 180 places in 15.4 seconds from the datacenter server and 174 in 25.4 through the proxy. To check that the stop is Google's and not the script's, we asked by hand for offsets 0 to 420, three times. Every page up to offset 160 held 20 rows, offset 180 held 0 or 4, and every page from 200 to 420 was empty; the runs held 157 to 179 distinct places, because pages repeat some. The ceiling is per query and map view, so a bigger area needs more views.
This script, city_to_csv.py, searches a 3-by-3 grid of views 2,000 metres apart, keeps each place once by cid, and drops places more than 5 km from the centre, because a view also returns places far from it. Save it next to google_maps_scraper.py, which it imports.
import argparse
import math
import sys
import time
from google_maps_scraper import Maps, ScrapeError, write_csv
METRES_PER_DEGREE = 111_320
def main():
parser = argparse.ArgumentParser(description="Search a grid of map views around a point and save every place once.")
parser.add_argument("query")
parser.add_argument("lat", type=float)
parser.add_argument("lng", type=float)
parser.add_argument("output")
parser.add_argument("--grid", type=int, default=3, help="grid x grid map views (default 3)")
parser.add_argument("--step", type=float, default=2000, help="metres between view centres (default 2000)")
parser.add_argument("--radius", type=float, default=5000, help="drop places farther than this many metres from the centre")
parser.add_argument("--proxy")
args = parser.parse_args()
maps = Maps(args.proxy)
places = {} # cid -> place, so a place seen in two views is kept once
per_lng = METRES_PER_DEGREE * math.cos(math.radians(args.lat))
half = (args.grid - 1) / 2
for row in range(args.grid):
for col in range(args.grid):
lat = args.lat + (row - half) * args.step / METRES_PER_DEGREE
lng = args.lng + (col - half) * args.step / per_lng
try:
found = maps.search(args.query, 200, lat, lng, args.step)
except ScrapeError as exc:
print(f"view {row},{col}: {exc}", file=sys.stderr)
continue
fresh = sum(1 for p in found if p["cid"] not in places)
places.update({p["cid"]: p for p in found})
print(f"view {row},{col}: {len(found)} places, {fresh} new, {len(places)} so far")
time.sleep(1)
def metres_away(p): # flat-earth distance, fine at city scale
return math.hypot((p["latitude"] - args.lat) * METRES_PER_DEGREE, (p["longitude"] - args.lng) * per_lng)
near = [p for p in places.values() if p["latitude"] is not None and metres_away(p) <= args.radius]
print(f"kept {len(near)} of {len(places)} places within {args.radius:.0f} m of the centre")
if near:
write_csv(args.output, near)
print(f"wrote {args.output}")
else:
sys.exit(1)
if __name__ == "__main__":
main()
python city_to_csv.py "coffee shops" 47.6062 -122.3321 seattle.csv
Output on 2026-10-17 from the datacenter server, after 143.9 seconds (the proxy run took 202.2) (stderr notes left out):
view 0,0: 197 places, 197 new, 197 so far
view 0,1: 181 places, 33 new, 230 so far
view 0,2: 198 places, 43 new, 273 so far
view 1,0: 199 places, 52 new, 325 so far
view 1,1: 165 places, 19 new, 344 so far
view 1,2: 163 places, 20 new, 364 so far
view 2,0: 195 places, 59 new, 423 so far
view 2,1: 179 places, 16 new, 439 so far
view 2,2: 196 places, 27 new, 466 so far
kept 343 of 466 places within 5000 m of the centre
wrote seattle.csv
The file has 343 rows and 18 columns, one per field of search.
How do you get one place's details?
Run place with a name, a Google Maps URL or a cid; it returns the same record search does, for one place you name, and takes several places at once. A name uses the top search hit, so pass the cid for an exact place. place "Louvre Museum Paris" took 1.2 seconds from the datacenter server and 2.7 through the proxy: 380,396 reviews, rating 4.7.
place "Times Square New York" returned phone and hours as null, since its listing has neither. Overcast Coffee lists all seven days: 7 AM-6 PM Monday to Friday and 8 AM-5 PM at the weekend. An unknown cid exits in 0.3 seconds with Error: No place found for 0x1234abcd:0x5678ef01.
How do you scrape a place's Google Maps reviews?
Run reviews with a cid, name or URL and --limit N; it returns reviews newest first, 20 per request, each with rating, text, language, time, photo links and the owner's reply. The loop follows the cursor Google sends back and stops when a page has no reviews or no cursor:
rows = at(doc, 1, 10, 2) or []
out.extend(r for r in (clean_review(row, authors) for row in rows) if r)
cursor = text(at(doc, 1, 10, 6))
if not rows or not cursor:
break
time.sleep(0.5)
reviews 0x54906b88c68b6ae9:0x675e801f4206257f --limit 100000 returned 316 reviews in 17.3 seconds from the datacenter server and 27.8 through the proxy. The place page shows 316. Of the 316, 90 are star-only ratings with text null, 64 have an owner reply, 66 have photos and 5 were edited. Trimmed output, two reviews:
[
{
"review_id": "Ci9DQUlRQUNv...",
"rating": 5,
"text": null,
"language": null,
"posted_at": "2026-10-04T17:38:59+00:00",
"edited": false,
"relative_time": "a week ago",
"photos": [],
"owner_response": null,
"owner_response_time": null
},
{
"review_id": "Ci9DQUlRQUNv...",
"rating": 3,
"text": "Its okay. Just a standard cafe. Nothing really stuck out about this place enough for me to...",
"language": "en",
"posted_at": "2026-01-27T21:43:33+00:00",
"edited": false,
"relative_time": "8 months ago",
"photos": [],
"owner_response": "Hey ...",
"owner_response_time": "8 months ago"
}
]
A loop that ends on a missing cursor can end early, so we checked it against two counts. The place page says 316, and a walk of the same cafe through our API stopped at 316 with the same 316 review ids. Reviewer names are left out unless you pass --authors, which adds the name and profile link. The script has no other sort or filter: newest first is the one order that walks the whole list, and other orders stopped early in our reviews API tests.
Does Google block the script?
Not from our datacenter server on 2026-10-17, which matches the README's own report of a few hundred requests from a datacenter IP. This check catches explicit blocks, and it raised none in any of our runs:
# Google sends blocked clients to /sorry/ with a 302 or a 429.
if resp.status_code in (302, 403, 429) or "/sorry/" in resp.url or "unusual traffic" in resp.text[:5000]:
Every command ran both ways; our test was one server and one proxy provider.
| Command | Datacenter, no proxy | Residential proxy |
|---|---|---|
search 20 places |
0.8 s, 20 places | 1.3 s, 20 places |
search --limit 100 -o dentists.csv |
7.2 s, 100 places | 12.6 s, 100 places |
search --limit 300 |
15.4 s, 180 places | 25.4 s, 174 places |
place "Louvre Museum Paris" |
1.2 s | 2.7 s |
reviews Apple Fifth Avenue, --limit 100 |
6.3 s, 100 reviews | 15.3 s, 100 reviews |
reviews Overcast Coffee, all |
17.3 s, 316 reviews | 27.8 s, 316 reviews |
city_to_csv.py, 3-by-3 grid |
143.9 s, 343 places | 202.2 s, 323 places |
Of 14 runs from the datacenter server, 12 returned data. The two errors were a made-up cid and a made-up place name, both "No place found"; none was a block. The proxy was 1.25 to 2.43 times slower on all 12 runs we made both ways. The check cannot see a quieter answer: the proxy grid returned fewer places per view than the datacenter one, for a reason we did not find. The stronger sign is the reviews, 316 of 316 on both connections.
What the script can't do, and the API calls that do
The free script runs from one IP and holds its own copy of Google's request format, so it needs your proxy and your upkeep when Google changes the pages. We make ToolzerHub, and its Google Maps API returns the same three kinds of records as JSON with one API key and no proxy of yours. On 2026-10-17 this search took 1.7 seconds and returned 20 places with has_more true. Our API times here include about 0.4 seconds per call to start our command-line client. Trimmed to one place:
curl -H "x-api-key: $TOOLZERHUB_API_KEY" \
"https://api.toolzerhub.com/v1/google-maps/search?query=coffee&latitude=47.6062&longitude=-122.3321&span_metres=3000"
{
"cid": "0x54906ab2f0c61d05:0x771b2a7dce963d58",
"name": "Storyville Coffee Pike Place",
"address": "94 Pike St Top floor Suite 34, Seattle, WA 98101",
"city": "Seattle, WA",
"neighborhood": "Pike Pine Retail Core",
"phone_e164": "+12067805777",
"website_domain": "storyville.com",
"rating": 4.6,
"hours": { "days": [{ "day": "Saturday", "hours": ["6:59 AM–6 PM"] }], "status": "Open · Closes 6 PM" }
}
It adds a phone in E.164 form, the website's domain, the neighborhood and one photo per place (two sizes of the same image). Its reviews carry the reviewer's review count and photo count; on the first 20 reviews of the cafe above they ran from 0 to 3,011 reviews and from 0 to 8,401 photos. Page through a search with page from 1 to 10, and through reviews by sending each next_cursor back as cursor. This script runs the same 3-by-3 grid as city_to_csv.py, with the same 5 km filter. We ran it with its HTTP calls sent through our command-line client instead of requests.
import csv
import math
import os
import sys
import requests
BASE = "https://api.toolzerhub.com/v1/google-maps"
HEADERS = {"x-api-key": os.environ.get("TOOLZERHUB_API_KEY", "")}
METRES_PER_DEGREE = 111_320
def get(path, **params):
for attempt in range(2): # one retry on an HTTP error
try:
resp = requests.get(f"{BASE}/{path}", params=params, headers=HEADERS, timeout=90)
resp.raise_for_status()
return resp.json()["data"]
except requests.HTTPError:
if attempt:
raise
def main(query, lat, lng, output, grid=3, step=2000, radius=5000):
places, calls = {}, 0
per_lng = METRES_PER_DEGREE * math.cos(math.radians(lat))
half = (grid - 1) / 2
for row in range(grid):
for col in range(grid):
view_lat = lat + (row - half) * step / METRES_PER_DEGREE
view_lng = lng + (col - half) * step / per_lng
before = len(places)
for page in range(1, 11): # the API pages up to 10 times per view
data = get("search", query=query, latitude=view_lat, longitude=view_lng, span_metres=step, page=page)
calls += 1
for p in data["results"]:
places.setdefault(p["cid"], p)
if not data["has_more"]:
break
print(f"view {row},{col}: {len(places) - before} new, {len(places)} so far, {calls} calls")
def metres_away(p):
return math.hypot((p["latitude"] - lat) * METRES_PER_DEGREE, (p["longitude"] - lng) * per_lng)
near = [p for p in places.values() if metres_away(p) <= radius]
with open(output, "w", newline="", encoding="utf-8") as f:
out = csv.writer(f)
out.writerow(["cid", "name", "rating", "phone", "website", "address", "latitude", "longitude", "categories", "photo"])
for p in near:
out.writerow([p["cid"], p["name"], p["rating"], p["phone_e164"], p["website"], p["address"],
p["latitude"], p["longitude"], "; ".join(p["categories"]), (p["photos"] or [None])[-1]])
print(f"kept {len(near)} of {len(places)} places within {radius} m, {calls} calls -> {output}")
if __name__ == "__main__":
main(sys.argv[1], float(sys.argv[2]), float(sys.argv[3]), sys.argv[4])
export TOOLZERHUB_API_KEY=your_key
python api_grid.py "coffee shops" 47.6062 -122.3321 seattle_api.csv
Output on 2026-10-17, after 333.1 seconds (it makes its calls one at a time; the median call took 2.8 seconds and the slowest 13.4):
view 0,0: 165 new, 165 so far, 10 calls
view 0,1: 67 new, 232 so far, 20 calls
view 0,2: 78 new, 310 so far, 30 calls
view 1,0: 61 new, 371 so far, 40 calls
view 1,1: 30 new, 401 so far, 50 calls
view 1,2: 35 new, 436 so far, 60 calls
view 2,0: 66 new, 502 so far, 70 calls
view 2,1: 33 new, 535 so far, 80 calls
view 2,2: 36 new, 571 so far, 90 calls
kept 328 of 571 places within 5000 m, 90 calls -> seattle_api.csv
The script's run kept 343 places and the API's kept 328; 324 are in both lists and 347 in either. For reviews, 16 API calls returned the cafe's 316 reviews in 49.9 seconds, 316 of them the same ids the script returned.
Where the script is the better pick: it was faster here (143.9 seconds against 333.1 for the same grid, about 300 without the client start), and it returns opening hours for the full week, where the API returns today's. Search results from the API also leave maps_url null. Of the three jobs we tested, none needed the API. It is the route when the job must run unattended, with no proxy bill and no fix to ship when Google changes its pages.
What the API costs
A free key gives you 100 credits a month with no card: get one here. Every call on the three Google Maps routes is 1 credit, a search call returns up to 20 places and a reviews call up to 20 reviews, and failed calls are not charged. Paid plans are $29 for 20,000 credits ($1.45 per 1,000), $79 for 60,000 ($1.32), $199 for 165,000 ($1.21) and $495 for 500,000 ($0.99). The 90-call grid cost 90 credits, $0.09 to $0.13; the 316 reviews cost 16, about $0.02.
In the 7 days to 2026-10-17 the three routes had 21 successful calls, no server errors and no empty 200s, and 10 live calls on each returned data. Single calls took 1.3 to 7.8 seconds in those 30, and the reviews reference says the first call after a quiet period can take about 35 seconds, so set a timeout of 60 or more; the script above uses 90.
Is it legal to scrape Google Maps?
Scraping Google Maps breaks Google's terms; whether it is unlawful depends on your country and what you do with the data. The Google Maps End User Additional Terms (last modified January 27, 2026) say you may not "mass download or create bulk feeds of the content (or let anyone else do so)".
Google's general terms (effective July 30, 2026) bar "using automated means to access content from any of our services in violation of the machine-readable instructions on our web pages". On 2026-10-17 its robots.txt allowed /search?*tbm=map and /maps/preview and had no rule for the reviews path, so the conflict is with the mass-download clause, not robots.txt. Reviews can carry personal data. This is not legal advice.
Google's own Places API is the supported route, but it returns at most 5 reviews per place. The same approach for other platforms is in scraping TikTok with Python and scraping Threads with Python.
We are not affiliated with Google LLC.
Sources
- toolzerhub/google-maps-scraper, source and README (commits 11ce631 and e9c5f1d), https://github.com/toolzerhub/google-maps-scraper, run 2026-10-17
- Google Maps End User Additional Terms of Service (Last Modified: January 27, 2026), https://www.google.com/help/terms_maps/, read 2026-10-17
- Google Terms of Service (effective July 30, 2026), https://policies.google.com/terms, read 2026-10-17
- Google robots.txt, https://www.google.com/robots.txt, read 2026-10-17
- ToolzerHub pricing, https://toolzerhub.com/pricing, checked 2026-10-17
- ToolzerHub OpenAPI spec (credits), https://api.toolzerhub.com/openapi.json, read 2026-10-17