How to Scrape Google Maps for Local Business Leads
If you are building a B2B lead list for any local-services niche — home services, professional services, healthcare, hospitality — Google Maps is the richest public data source in the world. A single search returns the business name, address, phone, website, category, full opening hours, geographic coordinates, and the review profile. The catch is that Google does not give you that data programmatically without major terms-of-service constraints, so most lead programs end up scraping the public Maps interface. This guide walks the full pipeline: building the search URL, running the scrape, cleaning the output, and chaining geographic slices to cover an entire city.
Why Google Maps Beats Other Lead Sources
The honest field comparison looks like this. ZoomInfo and Apollo bias toward enterprise titles and have weak coverage of local-services SMBs. Yellow Pages has good US coverage but rarely returns a website. The bought-list market is full of stale data with no provenance. Google Maps sits in the middle: every actively-operating business in any developed market is on it, the data is current because owners maintain it themselves, and every record includes the website needed to chain an email-enrichment step.
The other reason it wins is the geographic primitive. Every Maps query is anchored on a @lat,lng,zoom viewport, which means you can systematically grid-cover a city by varying the center point — something you cannot do with a category-and-city search on a directory site.
What Google Maps Actually Returns
Per business, a /search result gives you:
| Field | Example |
|---|---|
name | Smith Family Dental |
address | 123 Yonge St, Toronto, ON M5C 1W4, Canada |
address_parts | ["123 Yonge St", "Toronto", "ON M5C 1W4", "Canada"] |
category | Dentist |
categories | ["Dentist", "Cosmetic dentist", "Dental clinic"] |
neighborhood | Financial District |
latitude | 43.6532 |
longitude | -79.3832 |
rating | 4.7 |
reviews | 184 |
phone | +1 416-555-0142 |
phone_raw | 4165550142 |
website | https://smithfamilydental.ca |
website_label | smithfamilydental.ca |
timezone | America/Toronto |
hours | {"Monday":"08:00–18:00", "Tuesday":"08:00–18:00", ...} |
feature_id, cid, data_id | Google's internal identifiers |
What it does not include: email addresses, decision-maker names, employee count, or revenue. Those require a separate enrichment step keyed on the website field (see the FAQ above for the chained workflow).
Building the Search URL
This is the trick that makes Google Maps scraping straightforward: every Maps search is fully encoded in the URL. You build the search interactively in the Maps UI, then copy the URL.
- Open
google.com/mapsin a browser. - Type your category —
plumbers,dentists,law firms— and submit. - Pan and zoom to the geographic area you want to cover.
- Once the result list looks right, copy the URL from the browser bar.
You will get something like:
https://www.google.com/maps/search/plumbers/@30.2672,-97.7431,13z
The structure is /maps/search/<query>/@<lat>,<lng>,<zoom>z. The @ block is the viewport. The zoom level controls how dense the result set is — 13z is a single neighborhood, 11z is a city, 9z is a metro. For lead-gen scraping, 12z–14z per slice is the sweet spot: tight enough that Maps returns local-relevant results, wide enough that one query covers a useful area.
Three example URLs to test with:
https://www.google.com/maps/search/plumbers/@30.2672,-97.7431,13z
https://www.google.com/maps/search/law+firms/@53.4808,-2.2426,12z
https://www.google.com/maps/search/dentists/@43.6532,-79.3832,12z
(Austin TX plumbers, Manchester UK law firms, Toronto dentists.)
The API Call
Every LogPose Google Maps endpoint is asynchronous — submit a job, poll until done, fetch the result. Submit with curl first to confirm your URL works:
curl -G "https://api.logposervices.com/api/v1/ecommerce/googlemaps/search" \
-H "X-API-Key: lp_xxxxxxx" \
--data-urlencode "url=https://www.google.com/maps/search/plumbers/@30.2672,-97.7431,13z" \
--data-urlencode "pages=5"
# → {"job_id": "gm_8f3a..."}
curl -H "X-API-Key: lp_xxxxxxx" \
"https://api.logposervices.com/api/v1/jobs/gm_8f3a?wait=true&timeout=60"
curl -H "X-API-Key: lp_xxxxxxx" \
https://api.logposervices.com/api/v1/jobs/gm_8f3a/result
Google Maps returns about 20 results per page, so pages=5 is roughly 100 leads from one viewport. Most 5-page jobs finish in 60–90 seconds.
The Python Pipeline
This is the script most teams end up running on a cron. It takes one search URL, pulls 5 pages, and writes a CSV with the columns your SDRs actually care about.
import os, time, csv, requests
API_KEY = os.environ["LOGPOSE_API_KEY"]
BASE = "https://api.logposervices.com/api/v1"
HEADERS = {"X-API-Key": API_KEY}
def submit_and_wait(path: str, params: dict, timeout_s: int = 120) -> dict:
r = requests.get(f"{BASE}/{path}", params=params, headers=HEADERS, timeout=30)
r.raise_for_status()
job_id = r.json()["job_id"]
deadline = time.time() + timeout_s
while time.time() < deadline:
s = requests.get(f"{BASE}/jobs/{job_id}", headers=HEADERS, timeout=15).json()
if s["status"] == "completed":
break
if s["status"] == "failed":
raise RuntimeError(s.get("error", "unknown failure"))
time.sleep(2)
else:
raise TimeoutError(f"job {job_id} did not finish in {timeout_s}s")
return requests.get(f"{BASE}/jobs/{job_id}/result", headers=HEADERS, timeout=15).json()
def scrape_to_csv(search_url: str, pages: int, out_path: str) -> int:
data = submit_and_wait(
"ecommerce/googlemaps/search",
{"url": search_url, "pages": pages},
)
rows = data["listings"]
with open(out_path, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(
f,
fieldnames=["name", "phone", "website", "rating", "reviews", "address"],
extrasaction="ignore",
)
w.writeheader()
for r in rows:
w.writerow(r)
return len(rows)
if __name__ == "__main__":
n = scrape_to_csv(
"https://www.google.com/maps/search/plumbers/@30.2672,-97.7431,13z",
pages=5,
out_path="austin_plumbers.csv",
)
print(f"wrote {n} leads")
Run it once and you have a clean lead list. Run it nightly with a different category, and you are building a multi-niche pipeline.
Cleaning the Output for Sales Use
Raw output from one 5-page job is usually 80–110 rows. Three cleaning steps make it sales-ready:
import pandas as pd
df = pd.DataFrame(data["listings"])
# 1. Normalize phone to a stable key (digits only)
df["phone_key"] = df["phone_raw"].fillna("").astype(str)
# 2. Drop rows with no phone and no website — useless for outbound
df = df[(df["phone_key"].str.len() >= 10) | (df["website"].notna())]
# 3. Dedupe by phone first, then by Google's cid as fallback
df = df.drop_duplicates(subset="phone_key", keep="first")
# 4. Quality filter — businesses with at least a handful of reviews are
# overwhelmingly more likely to still be operating
df = df[df["reviews"].fillna(0) >= 3]
# 5. Sort by rating desc within neighborhood for SDR-friendly review order
df = df.sort_values(["neighborhood", "rating"], ascending=[True, False])
df.to_csv("austin_plumbers_clean.csv", index=False)
The reviews >= 3 filter is the single highest-leverage cleanup step. Zero-review listings are disproportionately closed businesses, ghost listings, and Google duplicates that the deduper missed. Dropping them removes about 15% of rows and 80% of the bad-data complaints from your sales team.
Scaling Beyond a Single Viewport
One viewport gives you a neighborhood. To cover an entire metro, slice geographically and chain calls. Two patterns work in production.
Grid cover. Pick the center lat/lng for each major neighborhood in your target city, run one 5-page scrape per centroid, then merge and dedupe by cid. For Austin TX, that might be downtown, south, east, north, and the suburbs (Round Rock, Cedar Park, Pflugerville). Five viewports × 100 results × 80% dedupe yield ≈ 400 unique businesses for one category.
Bulk submission. Instead of running calls sequentially in Python, submit the whole grid in one bulk request and let the platform schedule them across the proxy pool:
requests.post(
"https://api.logposervices.com/api/v1/ecommerce/googlemaps/search/bulk",
headers={"X-API-Key": os.environ["LOGPOSE_API_KEY"]},
json={
"targets": [
{"url": "https://www.google.com/maps/search/plumbers/@30.2672,-97.7431,13z", "pages": 5},
{"url": "https://www.google.com/maps/search/plumbers/@30.5083,-97.6789,13z", "pages": 5},
{"url": "https://www.google.com/maps/search/plumbers/@30.4394,-97.7733,13z", "pages": 5},
],
},
).raise_for_status()
Bulk runs in parallel up to your concurrency cap, which cuts a 10-viewport grid from 15 minutes sequential to 2–3 minutes wall-clock.
For weekly net-new-listing alerts on a saved set of viewports, see the diff-loop pattern documented for the Yellow Pages equivalent — the same logic applies to Maps if you key on cid. Full walkthrough in How to monitor Yellow Pages for new businesses in your category.
Legality and Ethics
Google Maps business data is public and indexed by every search engine. Scraping it for B2B lead generation is on settled legal ground in the US (CFAA does not apply to public data per hiQ v. LinkedIn) and is broadly compliant in the EU under GDPR's legitimate-interest basis for B2B contact data — provided your downstream outreach respects the directives that actually regulate marketing communications. Cold-call regulation varies by state and country; cold-email regulation under CAN-SPAM, CASL, and GDPR is the part that requires the real legal review. The scrape is not the risky step.
Common Mistakes
- Pasting the wrong URL form. Google Maps has three URL shapes:
/maps/place/...,/maps/search/..., and shortgoo.gl/maps/...links. Only/maps/search/...is what the search endpoint expects; use/placeURLs against theplaceendpoint instead. - Setting the zoom too wide.
@30.27,-97.74,9zcovers all of central Texas and returns a sparse, low-relevance result set. Stay at12z–14zper viewport. - Skipping geographic slicing. Running a single 50-page job at
10zreturns 100 results because Maps caps the result density, not 1,000 — that is why you grid-cover instead. - Treating
reviews=0as a signal of a new business. It is more often a signal of a closed or duplicate listing. Filter it out. - Ignoring the Cloudflare 100-second edge timeout.
api.logposervices.comsits behind Cloudflare, so a job that takes 100+ seconds returns a 524 to your client even though the job continues server-side. Always poll for status; never expect a synchronous response on a big page count.
Get Started
- Sign up at logposervices.com and generate an API key under Tool → API Keys.
export LOGPOSE_API_KEY=lp_xxxxxxx- Pick a search URL from the Maps UI and run the Python script above against it.
Related reading: How to build a B2B lead list from Yellow Pages (no code) for when the website field isn't critical, How to monitor Yellow Pages for new businesses for the recurring-refresh pattern, and the web scraping API guide for the broader DIY-vs-managed comparison.
External: Google Maps, Hunter.io email enrichment, hiQ Labs v. LinkedIn.