Google Maps scraper for local leads: every business in a city with phone, website and hours
Sales teams and agencies build local lead lists from Google Maps: every plumber in Denver, every dentist in Austin. One Google Maps search stops at about 200 places, and copying listings by hand does not scale.
arcadium/google-maps-places splits the area into map cells, searches each one and splits again where a cell is still full, so a city search goes past one result list. Each business is one JSON row: name, categories, full address, coordinates, rating and review count, the website and phone the business publishes on its listing, opening hours for the next 7 days and booking links. A free coverage row per search says how many cells were searched and why the search stopped.
Cost of this example on the Apify Free plan: 300 × $0.002 = $0.60 ($0.30 on the Apify Gold plan and higher), plus $0.001 per GB of memory when the run starts.
What you need
- An Apify account and API token.
- A business type and an area, for example
plumberinDenver, CO.
Step 1: Run it in the Apify Store (no code)
- Open arcadium/google-maps-places on the Apify Store and sign in (a free Apify account works).
- Type the business type into Search terms, for example
plumber. Several terms share one location. - Set Location to a city, district or postcode, for example
Denver, CO. - Set Max places per search to 300 (0 means every place the search finds) and turn on Skip closed places.
- Optional: set a maximum cost per run in the run options as a hard cap.
- Click Start. Rows appear in the Output tab; export them as JSON, CSV or Excel.
The same input as JSON (paste it in the JSON tab of the input form):
{
"searchTerms": [
"plumber"
],
"location": "Denver, CO",
"maxPlacesPerSearch": 300,
"skipClosed": true
}
Step 2: Call it over the Apify API
One HTTP call runs the actor and returns its rows. It suits runs up to about five minutes; for longer runs start the run asynchronously and read the dataset when it finishes.
curl -X POST "https://api.apify.com/v2/acts/arcadium~google-maps-places/run-sync-get-dataset-items" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"searchTerms":["plumber"],"location":"Denver, CO","maxPlacesPerSearch":300,"skipClosed":true}'
Step 3: Run it from Python
With apify-client 3.x, call() returns a Run object: read the dataset id as run.default_dataset_id (older examples use run["defaultDatasetId"], which no longer works).
# pip install "apify-client>=3"
import csv, os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("arcadium/google-maps-places").call(run_input={
"searchTerms": ["plumber"],
"location": "Denver, CO",
"maxPlacesPerSearch": 300,
"skipClosed": True,
})
rows = list(client.dataset(run.default_dataset_id).iterate_items()) # apify-client 3.x
keys = ["placeId", "name", "category", "address", "city", "postalCode", "phone",
"website", "rating", "reviewCount", "latitude", "longitude", "placeUrl"]
with open("plumbers-denver.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore")
w.writeheader()
w.writerows(r for r in rows if r.get("rowType") == "place")
for c in (r for r in rows if r.get("rowType") == "coverage"):
print("delivered", c.get("delivered"), "stop", c.get("stopReason"), "full cells", c.get("cellsSaturatedAtMin"))
Step 4: Run it from JavaScript
// npm install apify-client
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('arcadium/google-maps-places').call({
"searchTerms": [
"plumber"
],
"location": "Denver, CO",
"maxPlacesPerSearch": 300,
"skipClosed": true
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items);
Step 5: Ask an AI assistant (MCP)
Apify’s hosted MCP server turns the actor into one tool. Add it to Claude, Cursor, VS Code or any MCP client:
{
"mcpServers": {
"apify": {
"url": "https://mcp.apify.com/?tools=arcadium/google-maps-places",
"headers": {
"Authorization": "Bearer <APIFY_TOKEN>"
}
}
}
}Then ask, for example:
Use arcadium/google-maps-places to list plumbers in Denver, CO with a rating of 4.5 or more. Give me name, phone, website and review count, sorted by review count.
More clients and the local setup on the MCP page.
Businesses without a website
Web agencies look for local businesses that have no website on their listing. Set website to without (or with for the opposite). Filters run on the places Google returns, so you pay only for the places that pass.
{
"searchTerms": [
"restaurant"
],
"location": "Austin, TX",
"website": "without",
"maxPlacesPerSearch": 0
}
Check what the search covered
Each search ends with a free coverage row: delivered places, cellsSearched, cellsSaturatedAtMin (cells still full at the smallest size, where places may be missing; lower minCellKm to go deeper) and stopReason (end when the area is exhausted, limit when your maximum was reached, budget when the page budget or the run’s maximum charge stopped it).
Your own area: a radius or a polygon
Instead of a place name, pass latitude, longitude and radiusKm for a circle, boundingBox, or a GeoJSON polygon. Add minRating and categoryFilter to keep only the places you want. Re-run monthly and compare on placeId, which stays the same between runs.
Output fields
Every row has a rowType; filter on it first. Failed inputs come back as free rows with a code and message. The fields this tutorial uses, from the actor’s dataset schema:
| Field | What it holds |
|---|---|
rowType | Kind of row. Filter on this first. 'place': one business (charged). 'coverage': one free row per search with places delivered, map cells searched and why it stopped. 'error': an input could not be read (free). |
placeId | Google place id (ChIJ...). Stable; use it to deduplicate between runs and with the Google Places API. |
name | Business name as Google Maps shows it. |
category | Main Google category ('Dentist'). |
address | Full address as Google prints it. |
city | City or town of the address. |
postalCode | Postal code. |
latitude | Latitude (WGS84). |
longitude | Longitude (WGS84). |
rating | Google star rating, 1-5. |
reviewCount | Number of Google reviews. |
website | Website the business lists on Google. |
phone | Business phone as printed in its country, as published on the Google listing; null when includePhones is off or the listing has none. |
openingHours | Opening hours of the next 7 days: [{day, date, hours: ['8 AM–5 PM']}] (holiday-aware, dated). |
closed | True when Google marks the place closed (permanently or temporarily). |
delivered | Coverage rows: places delivered for the search. |
cellsSaturatedAtMin | Coverage rows: cells that still had 100+ places at the smallest cell size (some places there may be missing). |
stopReason | Coverage rows: why the search ended (limit = max places reached, end = area exhausted, budget = run charge limit, error). |
All fields: Google Maps Places Scraper guide.
Cost per run
Pay per event: you pay for the rows delivered, at the actor’s live Apify price on 2026-11-03. Error, coverage and summary rows are free.
| What | Rows | Price per row (Free plan) | Subtotal |
|---|---|---|---|
| Place: 1 search term, up to 300 places | 300 | $0.002 | $0.60 |
Cost of this example on the Apify Free plan: 300 × $0.002 = $0.60 ($0.30 on the Apify Gold plan and higher), plus $0.001 per GB of memory when the run starts. Higher Apify plans pay less per row. The Store page always has the current price.
Limitations
- No email addresses: the actor returns the phone and website the business publishes on its Google listing. A listing named after a person (
personNamed: true, for example a sole practitioner) can still be personal data under GDPR; you are responsible for using it lawfully. - The cost example assumes the search reaches 300 places; a smaller area returns fewer and costs less. Coverage and error rows are free.
- Price level and popular times are not in the data Google serves without signing in; they are not delivered.
closeddoes not tell permanently and temporarily closed apart.- One location per run; several search terms share it. When both a location and coordinates are given, the coordinates are used (with a warning).
- Run time: each place with details is one more request (~0.5 s). A run reads at most 4,000 place details; places past that come without details (not charged for details, counted as
detailsCapped). The default run timeout is 1 hour: rows delivered before a timeout are kept and charged, but the coverage row of the search in progress is not written. For more than ~4,000 places with details, raise the timeout and split the run.
Links
- Google Maps Places Scraper: fields, price table and sample output
- How to use Google Maps Places Scraper: every input field, n8n, LangChain
- arcadium/google-maps-places on the Apify Store
- All tutorials