Skip to main content
Thirdwatchthirdwatch
Business & local data

Build a City-by-City Local Lead List From Google Maps (2026)

Decompose a metro into suburb-by-trade Google Maps queries, dedupe on place_id, and budget maxResults so you cover a whole territory without paying twice.

Jul 28, 2026 · 7 min read · 1,705 words
See the scraper →

A single Google Maps search saturates at roughly 120 businesses, so a metro cannot be covered with one query no matter how high you set the result cap. The fix is coverage geometry: decompose the territory into suburb-by-trade queries, run them in one job, and dedupe on place_id so overlapping queries never bill you twice for the same business. Thirdwatch's Google Maps Lead Enrichment Actor does discovery and website contact extraction in the same run.

Why territory coverage is a geometry problem, not a scraping problem

You are opening three metros next quarter and you need every plumber, electrician and HVAC contractor in each one, with a contact channel attached. The temptation is to type plumbers in Phoenix AZ, set the result cap to 1,000, and walk away. That run returns roughly 120 rows and then stops finding new places. You conclude the market is small. It is not — Google simply stopped paginating.

The scale of the gap is easy to underestimate. The US Small Business Administration counts roughly 33 million small businesses nationally, and a single metro's trade category routinely runs into the thousands. One query returning 120 of them is a 3-5 percent sample, biased toward whatever Google considers most prominent near the centroid it inferred from your text.

So the real work is partitioning. You want a set of queries whose union covers the territory, whose individual cells stay under the saturation ceiling, and whose overlaps are cheap to collapse afterwards. Get that right and coverage becomes a spreadsheet problem you can audit. Get it wrong and you pay for the same downtown plumbers five times while the suburbs stay invisible.

How does this compare to the alternatives?

Three routes exist for building a city-by-city local lead list. The DIY route means writing both a Maps pagination client and a website contact crawler, then maintaining both against layout drift. A generic scraping API gives you HTML and leaves discovery, ranking of contact pages, obfuscated-email decoding and domain validation to you. A purpose-built Actor collapses the whole path — query, discover, visit each site, extract contacts, validate the domain — into one run whose output is already flat and typed.

Approach Cost model Reliability Setup time Maintenance
DIY Python (Maps client + site crawler) Your servers and bandwidth Breaks on layout and pagination drift 2-4 weeks Ongoing, two codebases
Generic scraping API Subscription plus per-request Fetches pages, extracts nothing 3-5 days You own all parsing
Thirdwatch Google Maps Lead Enrichment Transparent pay-per-result Discovery plus enrichment in one run Under an hour Handled for you

How to build a city-by-city Google Maps lead list in 4 steps

How many businesses does one Google Maps query actually return?

Measure the ceiling before you plan around it. Run one deliberately broad query with a cap far above what you expect, then count distinct place_id values.

import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])

run = client.actor("thirdwatch/google-maps-lead-enrichment").call(run_input={
    "queries": ["plumbers in Phoenix AZ"],
    "maxResults": 400,
    "maxPagesPerSite": 4,
    "concurrency": 8,
    "verifyEmailDomains": True,
})

rows = list(client.dataset(run["defaultDatasetId"]).iterate_items())
print(len(rows), len({r["place_id"] for r in rows}))

You will see the row count plateau near 120 regardless of the maxResults you asked for. That number is your per-cell budget for every plan that follows.

How do I decompose a metro into suburb-by-trade queries?

Build a query matrix: the cross product of your suburb list and your trade list. Each cell is one narrow query that comfortably fits under the ceiling, and the union of cells covers the metro.

SUBURBS = [
    "Scottsdale AZ", "Tempe AZ", "Mesa AZ", "Chandler AZ", "Glendale AZ",
    "Gilbert AZ", "Peoria AZ", "Surprise AZ", "Avondale AZ", "Goodyear AZ",
]
TRADES = ["plumbers", "electricians", "HVAC contractors", "roofing contractors"]

queries = [f"{trade} in {suburb}" for suburb in SUBURBS for trade in TRADES]
print(len(queries))  # 40 cells

run_input = {
    "queries": queries,
    "maxResults": 1000,
    "maxPagesPerSite": 4,
    "concurrency": 8,
    "verifyEmailDomains": True,
    "onlyWithContact": False,
}

Two rules keep the matrix honest. Put the location inside the query text — there is no separate location field, and plumbers in Tempe AZ is what the Actor resolves. And prefer suburb names over radius language: plumbers near downtown Phoenix re-centres on the same core you already covered.

How do I budget maxResults across a query matrix?

maxResults is a global cap across all queries, not a per-query cap, so a 40-cell matrix with a cap of 1,000 will exhaust the budget on the first cells and never reach the rest. Size the cap to the matrix, or split the matrix into batches you can reason about.

CELL_CEILING = 120          # observed saturation per query
EXPECTED_YIELD = 0.45       # most suburb x trade cells return far less

def batches(queries, per_batch=10):
    for i in range(0, len(queries), per_batch):
        yield queries[i:i + per_batch]

for batch in batches(queries, per_batch=10):
    budget = int(len(batch) * CELL_CEILING * EXPECTED_YIELD)
    client.actor("thirdwatch/google-maps-lead-enrichment").call(run_input={
        "queries": batch,
        "maxResults": min(budget, 1000),
        "maxPagesPerSite": 4,
        "concurrency": 8,
        "verifyEmailDomains": True,
        "onlyWithContact": True,
    })

Batching also gives you a natural checkpoint: if a batch comes back thin, you learn it before you have spent the whole territory budget. Setting onlyWithContact to true drops businesses with no email, phone or social profile anywhere, which trims rows you were never going to use.

How do I dedupe businesses that appear in more than one query?

Use place_id as the primary key. It is stable across queries, spelling variants and address formats, so the same business surfacing in plumbers in Tempe AZ and HVAC contractors in Tempe AZ collapses cleanly. Then use email_domain and the website host as a secondary key to fold multi-location chains that hold distinct place IDs but share one inbox.

from urllib.parse import urlparse

by_place, by_domain, leads = {}, {}, []

for row in all_rows:                       # concatenated across every batch
    pid = row.get("place_id")
    if pid and pid in by_place:
        by_place[pid]["source_queries"].append(row["source_query"])
        continue

    host = urlparse(row.get("final_url") or row.get("website") or "").netloc.lower()
    key = row.get("email_domain") or host.removeprefix("www.")
    if key and key in by_domain:
        by_domain[key]["locations"].append(row["business_address"])
        continue

    row["source_queries"] = [row["source_query"]]
    row["locations"] = [row.get("business_address")]
    leads.append(row)
    if pid:
        by_place[pid] = row
    if key:
        by_domain[key] = row

print(len(all_rows), "rows ->", len(leads), "unique businesses")

Keep the collapsed source_queries list. It tells you which cells overlap, which is exactly the signal you need to prune redundant queries on the next metro.

How do I confirm a city is actually covered before moving on?

Count rows per cell and read the two failure shapes. A cell at or near the ceiling is under-covered and needs splitting further. A cell at zero is either a genuinely absent trade or a bad query string.

from collections import Counter

per_cell = Counter(r["source_query"] for r in all_rows)

for q in queries:
    n = per_cell.get(q, 0)
    if n >= 110:
        print("SPLIT  ", q, n)      # go finer: neighbourhood or sub-category
    elif n == 0:
        print("EMPTY  ", q)         # check spelling, or the trade is absent

Split a saturated cell by neighbourhood (plumbers in North Scottsdale AZ) or by sub-category (emergency plumbers, commercial plumbers). Re-run only the split cells; your dedupe pass absorbs the overlap.

Sample output

Every row is one business, carrying the Maps listing fields plus everything found on the business website. The fields that matter for coverage work are place_id (dedupe key), source_query (which cell produced it) and fetch_status (why a site yielded nothing).

[
  {
    "source_query": "plumbers in Tempe AZ",
    "business_name": "Desert Ridge Plumbing",
    "business_address": "1820 E Broadway Rd Ste 4, Tempe, AZ 85282",
    "business_phone": "(480) 555-0142",
    "business_category": "Plumber",
    "business_categories": ["Plumber", "Drainage service"],
    "business_rating": 4.7,
    "latitude": 33.4076,
    "longitude": -111.9216,
    "place_id": "ChIJ2xQ1Rd0SK4cRk7Qk1n0mV3E",
    "google_maps_url": "https://www.google.com/maps/place/?q=place_id:ChIJ2xQ1Rd0SK4cRk7Qk1n0mV3E",
    "website": "https://desertridgeplumbing.example/",
    "final_url": "https://www.desertridgeplumbing.example/",
    "emails": ["service@desertridgeplumbing.example"],
    "primary_email": "service@desertridgeplumbing.example",
    "primary_email_source": "mailto",
    "email_domain": "desertridgeplumbing.example",
    "email_is_freemail": false,
    "email_domain_mx_ok": true,
    "email_mx_provider": "google",
    "primary_phone": "+14805550142",
    "linkedin_url": "https://www.linkedin.com/company/desert-ridge-plumbing/",
    "facebook_url": "https://www.facebook.com/desertridgeplumbing",
    "social_profiles_count": 3,
    "contact_pages": ["https://www.desertridgeplumbing.example/contact/"],
    "fetch_status": "ok",
    "http_status": 200,
    "pages_fetched": 4,
    "has_contact": true,
    "scraped_at": "2026-07-28T09:12:04+00:00"
  },
  {
    "source_query": "HVAC contractors in Tempe AZ",
    "business_name": "Valley Air Systems",
    "business_category": "HVAC contractor",
    "business_rating": 4.4,
    "place_id": "ChIJvW8mLd4SK4cRQ1pQ7pOaX2A",
    "website": "https://valleyairsystems.example/",
    "emails": [],
    "primary_email": null,
    "primary_phone": "+14805550188",
    "instagram_url": "https://www.instagram.com/valleyairsystems/",
    "social_profiles_count": 1,
    "fetch_status": "ok",
    "http_status": 200,
    "has_contact": true,
    "scraped_at": "2026-07-28T09:12:11+00:00"
  }
]

The second row is the common case people forget to plan for: a reachable site with no published email at all, still contactable by phone and Instagram.

Common pitfalls

Raising maxResults does not defeat saturation. A single query stops producing new places around 120 businesses. Only narrower queries add coverage.

Roughly one business in four yields no email. In measurement, 18 of 105 successfully-read sites had no email anywhere across four pages. US home services are the weakest vertical at 55-60 percent email; wellness and design businesses abroad reach 85-90 percent. Budget your list size against the low end.

Crawl depth is capped at four pages per site on purpose. Going to ten pages on the sites that produced no email recovered 2 of 18, one of which was a regulator's address, while tripling bandwidth. There is no meaningful yield past four.

Domain validation is not mailbox validation. email_domain_mx_ok proves the domain can receive mail, not that info@ exists.

Between 5 and 11 percent of sites are unreachable — dead domains, broken hosting, aggressive edge protection. fetch_status and http_server_header tell you which. Enabling retryBlockedWithProxy lifts reachability by roughly 6 points at extra bandwidth cost. Businesses that list a Facebook page as their website appear with fetch_status: social_website and a populated facebook_url rather than a crawl.

The Actor handles the pagination, contact-page ranking, obfuscated-email decoding and domain checks; the coverage geometry above is the part that stays yours.

Related use cases

Frequently asked questions

Why does one Google Maps query stop at around 120 businesses?

Google Maps paginates a search result set and stops producing new places well before it exhausts a metro. In practice a single query saturates near 120 businesses, so covering a city means running many narrow queries rather than one broad one.

How do I stop paying twice for the same business across overlapping queries?

Dedupe on place_id, which is stable across queries and spelling variants. Use website host and email_domain as a secondary key to collapse multi-location chains that carry distinct place IDs but a single shared inbox.

How many queries do I need for a mid-sized metro?

Multiply target suburbs by target trades. A 25-suburb metro across 8 trades is 200 queries. Most cells return far fewer than 120 rows, so the matrix usually costs less than the arithmetic suggests.

Does every row come back with an email address?

No. Roughly three in four successfully-read sites yield an email, and US home-services verticals land closer to 55-60 percent. Rows without email still carry phone numbers and social profiles, so they remain contactable.

Can I feed in businesses I already scraped instead of running queries?

Yes. The businesses input accepts objects carrying a website field and passes their existing fields through onto the enriched rows, so you can enrich an export from another Maps scraper without re-running discovery.

Related

Try it yourself

100 free credits, no credit card.

About 30 real searches. Add the MCP to Claude or Cursor in two minutes.