Skip to content
CrawlerData
Menu
Guides

Build a local business lead list from Google results

Turn Google local results into a clean lead list: category and city searches, pagination, deduplication and CSV export, in Python, used responsibly.

CrawlerData teamPublished 7 min read

Contents

When you sell to local businesses, such as plumbers, dentists, gyms or restaurants, the best list of prospects is often the one Google already shows: the businesses that come up when someone searches for that service in that town. Google’s local results come with a rating, review count, address, phone number and website for each business, which is most of what you need to qualify a lead.

This guide turns those results into a clean lead list with the Google Local API: one search per category and place, pagination, deduplication, a little cleaning, and a CSV you can open in a spreadsheet or import into a CRM. The response excerpts are real, from searches we ran in October 2026.

What you get for each business

A request to the google-local endpoint returns local_results, one object per business. Here are two from a real search for plumbers in denver, page 2:

{
  "local_results": [
    {
      "position": 1,
      "title": "King Rooter & Plumbing",
      "rating": 4.7,
      "reviews": 489,
      "type": "Plumber",
      "address": "100 Fillmore St #500, Denver, CO 80206",
      "phone": "(303) 502-5651",
      "hours": "Open 24 hours",
      "website": "https://www.kingrooterandplumbing.com/?utm_source=google&utm_medium=search_engine&utm_campaign=gmb"
    },
    {
      "position": 2,
      "title": "Peach Tree Plumbing",
      "rating": 4.8,
      "reviews": 102,
      "type": "Plumber",
      "address": null,
      "phone": "(720) 298-1900",
      "hours": "Open 24 hours",
      "website": "https://peachtreeplumbing5280.com/"
    }
  ]
}

Each business has nine fields: position, title, rating, reviews, type (the category Google shows), address, phone, hours and website. A field is null when Google doesn’t show it. On page 2 of our search, 4 of the 10 businesses had no address, like Peach Tree Plumbing above, probably because they serve an area rather than list a street address, so write your code to handle null.

hours is the text Google showed at the moment of the request, such as "Open 24 hours" or "Closed · Opens 7 AM Mon". It’s a snapshot, not an opening-hours schedule, so don’t store it as one.

Set up

You’ll need Python 3.9+, pip install requests, and your API key in the CRAWLERDATA_API_KEY environment variable. Never put the key in the script itself.

import os
import re
import requests

API_URL = "https://api.crawlerdata.com/v1/jobs"
HEADERS = {"Authorization": f"Bearer {os.environ['CRAWLERDATA_API_KEY']}"}


def google_local(query, country="us", page=1):
    body = {
        "endpoint": "google-local",
        "params": {"query": query, "country": country, "page": page},
        "queue": "live",
    }
    resp = requests.post(API_URL, json=body, headers=HEADERS, timeout=180)
    resp.raise_for_status()
    data = resp.json()
    if data["job"]["status"] != "completed":
        raise RuntimeError(f"{query!r} page {page}: {data['job']['error']}")
    return data["result"]["local_results"]

Local searches were quick in our tests, about 3 to 4 seconds each on the live queue, and the timeout leaves plenty of room. For thousands of searches, the fast queue lets you submit everything at once and poll for results, at the same price.

Search by category and place

The query is the same text you’d type into Google: a category and a place, such as plumbers in denver. country sets which Google country you search from and takes one of 31 country codes, such as us, gb, de or au.

CATEGORIES = ["plumbers", "plumbing services"]
PLACES = ["denver", "aurora co", "lakewood co"]
MAX_PAGES = 2


def collect(country="us"):
    rows = []
    for category in CATEGORIES:
        for place in PLACES:
            query = f"{category} in {place}"
            for page in range(1, MAX_PAGES + 1):
                results = google_local(query, country=country, page=page)
                for r in results:
                    rows.append({**r, "query": query, "page": page})
                if not results:
                    break
    return rows

A few things we noticed from our own plumbers in denver searches:

  • Pages vary in size. Page 1 returned 20 businesses and page 2 returned 10. Each page is one request and costs 1 credit, and page goes up to 10.
  • Positions restart on every page. Page 2 started again at position 1, so keep the page number next to the position if ranking matters to you.
  • A city search covers the area around it. Page 1 for Denver included businesses with addresses in Westminster, Aurora, Golden, Centennial and other nearby towns. If you need only one city, filter on the address afterwards.

Several phrasings of the same category (“plumbers”, “plumbing services”, “emergency plumber”) and several nearby places turn up more businesses than one search, and more duplicates. That’s the next step.

Deduplicate

The same business shows up across phrasings, places and sometimes pages. Phone numbers were the most complete field in our results: all 30 businesses across both pages had one, while 4 had no address. So the key below uses the phone number’s digits first, then name plus address, then the website’s domain:

def norm(text):
    return re.sub(r"[^a-z0-9]+", " ", (text or "").lower()).strip()


def dedupe_key(r):
    if r.get("phone"):
        return ("phone", re.sub(r"\D", "", r["phone"]))
    if r.get("address"):
        return ("addr", norm(r["title"]), norm(r["address"]))
    if r.get("website"):
        return ("site", urlsplit(r["website"]).netloc.lower().removeprefix("www."))
    return ("name", norm(r["title"]))


def dedupe(rows):
    best = {}
    for r in rows:
        r = {**r, "website": clean_website(r.get("website"))}
        key = dedupe_key(r)
        if key not in best:
            best[key] = r
        else:
            best[key].setdefault("also_found_in", []).append(r["query"])
    return list(best.values())

No key is perfect. Two branches of a chain can share a central phone number, and a business that lists its address in one result but not another gets two different keys if it has no phone. For a lead list, a quick manual review of near-duplicates is usually enough.

Many businesses add tracking parameters to the website link on their Google listing. In our Denver results, links looked like https://www.benjaminfranklinplumbing.com/denver/?utm_source=organic&utm_medium=GBP&utm_campaign=gbp_main. Strip the tracking parameters before you store or deduplicate them:

from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode


def clean_website(url):
    if not url:
        return None
    parts = urlsplit(url)
    query = [(k, v) for k, v in parse_qsl(parts.query)
             if not k.lower().startswith("utm_") and k.lower() not in {"cid", "se_campaign"}]
    return urlunsplit((parts.scheme, parts.netloc.lower(), parts.path, urlencode(query), ""))

That link becomes https://www.benjaminfranklinplumbing.com/denver/. Keep the path: for multi-location businesses it often points to the local branch page, which is more useful than the homepage.

Export to CSV

import csv
from datetime import date

FIELDS = ["title", "type", "rating", "reviews", "address", "phone",
          "website", "hours", "query", "page", "position"]

leads = dedupe(collect())
with open(f"leads-{date.today().isoformat()}.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore")
    writer.writeheader()
    writer.writerows(sorted(leads, key=lambda r: (-(r["reviews"] or 0), r["title"])))

The date in the file name matters. Ratings, review counts and hours change, and businesses close. A list is only as good as the day you collected it, so refresh it before each campaign rather than reusing an old export.

Qualify the list

With rating and review counts in each row, you can segment before anyone picks up the phone. A few filters that tend to be useful:

established = [r for r in leads if (r["reviews"] or 0) >= 100 and r["website"]]
no_website = [r for r in leads if not r["website"]]
low_rated = [r for r in leads if r["rating"] is not None and r["rating"] < 4.0]

Which segment is a lead depends on what you sell. A web design agency might want no_website. A reputation management service might start with low_rated. A supplier might prefer established businesses with many reviews. Keeping type in the export lets you split mixed results, because a search for one category can return neighbouring ones too.

What it costs

Every request is 1 credit: one search, one page. The cost of a list is:

categories × places × pages

Two phrasings × 25 towns × 2 pages is 100 credits. New accounts get 50 free credits, and packs start at $50 for 50,000 credits ($1.00 per 1,000 requests), down to $0.30 per 1,000 on the largest pack. See pricing. Failed requests aren’t charged. A search that returns no businesses still counts as a completed request and costs 1 credit.

Use the data responsibly

Local results are public business listings, and lead generation from them is a normal business use. A few rules still apply.

In practice:

  • Collect the business fields you need, not more. This endpoint doesn’t return personal details, and you shouldn’t enrich the list with them by other means.
  • Honour opt-outs and keep a suppression list, so a business that says no doesn’t come back in next month’s refresh.
  • Don’t use ratings or reviews to target or harass a business owner, and don’t use the data to create or manipulate reviews.

Next steps

To see a search the way someone in a given city sees it, our free Google results by city checker runs without an account.

The local business data page covers this use case in more depth, including using the same searches for market maps. If you need more about a specific business, a search on its name with the Google Search API returns the organic results, plus the knowledge panel when Google shows one.

Frequently asked questions

Which fields does the Google Local endpoint return?
Each business in local_results has position, title, rating, reviews, type, address, phone, hours and website. Any of them except position and title can be null when Google doesn't show it.
What does a lead list cost?
1 credit per search page. Two phrasings across 25 towns at 2 pages each is 100 credits, or $0.10 worth of credits at the Starter rate of $1.00 per 1,000. Failed requests aren't charged.
How many businesses does one request return?
One page of Google local results. In our test, page 1 for plumbers in denver returned 20 businesses and page 2 returned 10. The count varies by query and place.
Can I target a specific city?
Yes. Put the place in the query, for example dentists in leeds, and set country to the matching country code, just as you would search on Google. You can also pass location, a Google location name such as Austin,Texas,United States.
How do I remove duplicates?
The same business often appears for several searches. Deduplicate on the phone number's digits where there is one, otherwise on normalised name and address.
Can I use the data for cold outreach?
Yes, within the privacy and direct-marketing rules where you and the businesses operate, and our Acceptable Use Policy. The data is business listings, not personal data for profiling individuals.
  • Google Local API
  • Lead generation
  • Local business data
  • Python
  • Tutorial