I needed a defensible list of roofing contractors across three Texas cities. Not a demo — an actual dataset I could reason about.
The scrape took about seven minutes. The next two hours went into figuring out why the numbers looked wrong.
Two things broke, and neither was a bug in my code. Both are structural properties of Google Maps: a hard result ceiling per query, and a deduplication trap that makes a clean-looking dataset quietly unusable.
First: Google Maps stops at roughly 120 results per query
Scrolling the Maps panel ends somewhere around 120 places. You can keep scrolling, the list just stops growing. There is nothing more to page through, so a bigger depth parameter buys you nothing.
Coverage therefore comes from query variation, not query depth:
CITIES = ["Dallas", "Houston", "Austin"] TEMPLATES = [ "roofing contractor in {city}, TX", "roofing company {city} TX", "roofer {city} TX", "commercial roofing {city} TX", ] queries = [t.format(city=c) for c in CITIES for t in TEMPLATES] # 12 searches Twelve searches, and the yield per query was nowhere near even:
| Listings returned | Queries |
|---|---|
| 112 | 2 |
| 60–62 | 2 |
| 46 | 1 |
| 20–27 | 3 |
| 5–15 | 4 |
506 unique listings after deduping on place_id.
Look at the two queries that returned 112. That is the ceiling, visible. Now look at the four that returned 5–15 — those look like wasted calls until you check the set difference against everything else. They contribute businesses no other query surfaces.
That is the actual argument for a query matrix: not volume, coverage. A single query per city got me roughly a quarter of the market.
It also means "how many plumbers are there in Chicago" is a question Google Maps cannot answer for you. You get a sample, and the sample leans toward whatever Google considers prominent.
Second: deduplication looks solved and isn't
After the run: 506 rows, 506 unique place_id values, zero duplicates. Clean. Ship it.
Then I grouped by normalized street address:
import re from collections import defaultdict def norm_addr(a: str) -> str: a = a.lower() a = re.sub(r"\b(suite|ste|unit|#)\s*[\w-]+", "", a) a = re.sub(r"[^a-z0-9 ]", " ", a) return " ".join(a.split()) by_addr = defaultdict(list) for row in rows: by_addr[norm_addr(row["address"])].append(row) collisions = {k: v for k, v in by_addr.items() if len(v) > 1} Seven addresses held 17 companies between them. All seven are virtual office suites in the three downtowns. A few are lead-generation shells renting a prestige address; the rest are one-person operations sharing a registered agent.
place_id cannot catch those, because they genuinely are different listings.
The mirror-image problem:
from collections import Counter brands = Counter(r["title"].strip().lower() for r in rows) chains = {b: n for b, n in brands.items() if n > 1} Seventeen brand names appeared at multiple locations, 36 listings in total. Those are real branches.
So you need both passes, and they answer different questions:
- dedupe by name → you delete legitimate branches
- dedupe by
place_id→ you email the same head office four times
I now run both and keep the collision lists rather than dropping rows. The point is to see them.
What the cleaned data looked like
Two numbers worth keeping:
| Metric | Value |
|---|---|
| Rated 4.5–5.0 | 88.3% |
| Rated below 3.5 | 6.1% |
| Median review count | 39 |
| Under 50 reviews | 53.8% |
| Under 400 reviews | 92.9% |
Ratings cluster hard, and that is structural rather than a scraping artefact. The review request goes out at the single happiest moment in the customer relationship, so the distribution inflates and converges on 4.7–5.0.
Practical consequence: rating is a poor discriminator in local services generally. Review count separates businesses much more usefully, because it correlates with whether the owner still answers their own phone.
The field you cannot scrape reliably
I re-ran a 52-listing sample with email extraction enabled. 48 had a website. Fourteen returned a usable address — 29.2% of the ones with a site.
The other 34 were contact forms, JavaScript-rendered footers, or an address baked into an image. Two of them rendered the address as text inside a PNG.
If your pipeline assumes has_website → has_email, it will silently drop about 70% of the list. Design around phone as the primary channel and treat email as a bonus, or you will spend a week building a mailer for a list that is mostly empty.
Five things I would do differently next time
- Log per-query yield from the first run. Pruning the query matrix should be a decision backed by numbers, not a guess. Four of my twelve queries looked useless and were not.
- Dedupe in two passes and keep both outputs.
place_idfor the strictly-identical, normalized address for the colliding. Deleting silently is how you end up not knowing your own dataset. - Persist the raw response. Review counts drift week to week. Without the raw payload you cannot distinguish a business that changed from a scrape that changed.
- Cap concurrency per proxy, not globally. A global semaphore means one slow proxy throttles the whole pool.
- Treat a row as
(place_id, scraped_at). The moment you drop the timestamp, you lose the ability to diff two runs, which is the only way to detect regressions in your own scraper.
Caveats
One vertical, one state, one afternoon. Texas roofing in November is not dental practices in Berlin, and the distribution will move between categories.
The results were served to a non-US IP pointed at US coordinates. A residential US IP will return a somewhat different set.
The ceiling and the dedup traps are not artefacts of this run. They are properties of Maps, and they will show up in any category.
Method notes: 12 searches (4 keyword variants × 3 cities), results deduplicated on place_id, 506 rows retained, single run on 14 November 2026. Email sample: 52 listings, one query, extraction enabled. I build a small Windows scraper for runs like this — full write-up with the distributions is here. I am the author, so treat that mention as a disclosure rather than a recommendation.
