How to Scrape Google Maps by City Without Missing Businesses

If you scrape Google Maps by city with one text search, you get a ranked sample of the city, not the city. Paging stops early, and the deep pages drift: page four of "restaurant in Lagos, Nigeria" returned restaurants in Lagos, Portugal. The fix is a grid of coordinate-anchored searches, each small enough that the result cap never bites, deduplicated on place_id. Below: how to size the cells, a Python sweep against the Locomint API, and the two filters that keep the set inside the city.

Why one search never returns all businesses in a city

A map search is built for a person looking at a screen, not for a census. It ranks results by relevance and by distance from the centre of the map, and it stops paging long before it has listed every matching business. Ask for "restaurant" in a capital city and the text search runs dry after a few pages, however many restaurants the city has.

The second problem is drift. Deep result pages relax the location constraint. In our own testing, page four of "restaurant in Lagos, Nigeria" started listing restaurants in Lagos, Portugal. A pipeline that pages until the results stop fills the tail of the list with businesses from the wrong country and calls that coverage. The Locomint search endpoint now cuts off a page that has nothing inside the resolved area, which ends paging early rather than wrongly, but it cannot conjure the businesses the ranking left out.

The third problem is invisible. Because ranking favours the centre, the businesses that never appear are the ones on the edges: the suburbs, the industrial estate, the second high street. Nothing in a single response tells you they exist.

How Google Maps grid search works

Stop asking about the city and ask about small pieces of it. Divide the area the city covers into a grid of cells and run a separate search anchored on the centre of each cell, with a radius that just covers it. Each search now competes for an area small enough that the result cap is rarely reached, so the ranking has nothing to leave out.

Businesses near cell boundaries turn up in two or three neighbouring searches. Every listing carries a stable place_id, so you deduplicate on it and the overlap disappears. Do not deduplicate on name: a chain has one name and many IDs.

Run the plain text search first and the grid second. The text search returns the well-known places in one or two calls; the grid finds the rest. Our own city sweeps do it in that order, with the grid centred on the viewport the text search resolved to, and a live run of "restaurant" in Lagos, Nigeria produced 500 unique places, all inside Lagos, in about 25 seconds.

Choosing a cell size for the grid

Cell size is a trade-off between cost and completeness. Large cells mean fewer searches but a higher chance that a dense cell hits the per-search cap and drops businesses. Small cells drop nothing but multiply the number of calls, and each search costs one credit per place returned, including the duplicates you will later discard. Starting points that have worked for us:

AreaStarting cell radiusNotes
Dense city centre, common category (cafe, restaurant, salon)500 to 800 mStart small; halve the radius for any cell that returns a full page
Whole city, common category1,000 to 1,500 mDozens of cells for a large city; centre cells will need splitting
Whole city, niche category (notary, physiotherapist)2,000 to 3,000 mFew matches per cell, so larger cells are safe
Metro region or small country5,000 m (the API default)Use a coarse grid to find the towns, then a fine grid inside each

The signal to watch is whether a cell returns a full page. If you asked for 50 and got 50, the cell has more: page it, or split it into four smaller cells. If you asked for 50 and got 12, the cell is exhausted, pagination.next_page is null, and you move on.

How to scrape Google Maps by city with the Locomint API

The search endpoint accepts either a location string or a coordinate triple lat, lng and radius in metres. The text form is the first pass. Write the location as "City, Country": under a US default locale, "Warsaw" on its own resolved to Warsaw, Indiana in our canary runs, and "Warsaw, Poland" cannot.

curl "https://api.locomint.io/v1/places/search?q=dentist&location=Porto,%20Portugal&limit=50" \
  -H "X-API-Key: $LOCOMINT_KEY"

The coordinate form is what the grid uses. The script below takes a bounding box for the city (read the south-west and north-east corners off any map), builds a grid of cells inside it, searches each cell, pages while the cell keeps returning full pages, and collects everything into a dictionary keyed on place_id.

import math
import httpx

API = "https://api.locomint.io/v1"
headers = {"X-API-Key": "lm_free_your_key_here"}

# Rough bounding box for Porto, Portugal: (south, west), (north, east)
south, west, north, east = 41.135, -8.690, 41.185, -8.560
radius_m = 1200
query = "dentist"

def cells(south, west, north, east, radius_m):
    step_lat = (radius_m * 1.6) / 111_000
    mid_lat = math.radians((south + north) / 2)
    step_lng = (radius_m * 1.6) / (111_000 * math.cos(mid_lat))
    lat = south + step_lat / 2
    while lat < north:
        lng = west + step_lng / 2
        while lng < east:
            yield round(lat, 5), round(lng, 5)
            lng += step_lng
        lat += step_lat

places = {}
with httpx.Client(base_url=API, headers=headers, timeout=60) as client:
    for lat, lng in cells(south, west, north, east, radius_m):
        page = 1
        while True:
            resp = client.get("/places/search", params={
                "q": query, "lat": lat, "lng": lng,
                "radius": radius_m, "limit": 50, "page": page,
            })
            resp.raise_for_status()
            body = resp.json()
            for p in body["data"]:
                places.setdefault(p["place_id"], p)
            page = body["pagination"].get("next_page")
            if not page:
                break

print(len(places), "unique places")
inside = [p for p in places.values()
          if p["address"]["country_code"] == "PT"]
print(len(inside), "inside the expected country")

The cell generator spaces centres at 1.6 times the radius, so neighbouring circles overlap and no gap opens between them. pagination.next_page is null when a cell has no further page, which ends paging for that cell; the API also cuts off deep pages that drift outside the area, so the loop cannot run away into a neighbouring country. Sum meta.credits_used from each response if you want the cost of the run as it happens, or call the free GET /v1/usage afterwards.

Practical tip: run the grid centre-first and stop on three consecutive low-yield cells. Sort the cells by distance from the middle of the bounding box; when three cells in a row each add fewer than a handful of new place_id values, you have reached the edge of the built-up area and every remaining cell is a credit spent on farmland. Our own sweep uses exactly that rule.

Keeping the results inside the city

Grid search removes most of the drift problem, because a coordinate-anchored search with a small radius has nowhere to wander. Two checks still belong in your pipeline. First, filter on address.country_code so a stray record from a same-named city elsewhere is dropped. Second, if you need a strict municipal boundary rather than a bounding box, compare each record's location.lat and location.lng against your own polygon after the run.

Do not filter on address.city. Localities are written the way the listing writes them: a district name inside the city, or, for Australian records, the state glued onto the city as "Melbourne VIC". Country code plus coordinates is the reliable pair; the city string is for display.

Service-area businesses, such as plumbers and cleaners who travel to the customer, have no storefront address and come back with service_area_business set to true and an empty street address. They still have a country and coordinates, so they survive the filters above; decide separately whether your list wants them.

Estimating coverage and cost before you run

Every place returned by a search costs one credit, and a business that appears in three overlapping cells is charged three times, once per response it appears in. Credits are reserved for limit when the request starts and settled to the number of places returned, so a thin cell costs what it returns, not what you asked for. Two things keep the overlap in check: cell spacing no tighter than the 1.6 factor above, and ending paging the moment next_page is null.

Before spending anything, call the free count endpoint with the same q and location. It returns the size of the first result page, up to 50, with two flags. exact: true means the page was not full, so that number is the whole answer and a grid would be wasted. at_least: true means 50 came back and there are more, which is the case the grid exists for. The answer is cached for 24 hours per query and each key may ask 50 fresh questions a day, after which it returns 429 until tomorrow, so use it for planning, not polling.

After the run, compare three numbers: what the text search alone returned, what the grid produced after deduplication, and what the count flagged. If the grid found fewer places than an at_least count implies, the bounding box was too tight or the central cells too coarse.

Doing this without being blocked

Coverage work means many requests in a short time, which is the pattern map platforms watch for. Locomint reads public, logged-out pages, keeps request rates conservative, and stops on any block, challenge or rate-limit response rather than routing around it. That is why a key on the Free plan is limited to 60 requests a minute (120, 300 and 600 on the paid plans), and why a full-city grid takes minutes rather than seconds. A typical search through the residential path takes two to five seconds.

In your own code, sleep for the number of seconds in Retry-After when you get a 429 with code rate_limited. A 503 with code source_unavailable means the data path is degraded; it also carries Retry-After, and the status page shows the daily canary result that triggered it. Write the grid loop to resume from the last completed cell, and a pause costs you time but no credits.

Where to start this week

Pick one category and one city you know well enough to judge the result. Call count first; if it says exact, a single text search with limit=50 is the whole job. If it says at_least, run the text search, note the number, then run the grid script above with a 1,200 m radius, deduplicate, and compare. The gap is what a single search was missing.

Once the list is complete, the details endpoint adds seven-day opening hours and website contacts for each place, and the guide to local business lead generation covers turning that into an outreach list. The free plan's 200 credits cover a niche category in a mid-sized city; a full sweep of a large city needs one of the paid plans.

Frequently asked questions

How many businesses can one Google Maps search return?

A single text search returns a ranked list that stops well before the real total for any busy category in a large city, and the deeper pages tend to wander outside the area you asked for. Treat one search as a sample of the city, not a census. To get close to all businesses in a city you have to split the area into cells and search each one.

Why do I get the same business twice from different grid cells?

Because neighbouring cells overlap, and a business near a cell boundary is a good match for both. That is expected and harmless as long as you deduplicate on place_id, which is stable for a business across searches. Never deduplicate on name alone; chains have many branches with identical names and different IDs.

Is scraping Google Maps by city allowed?

Locomint reads public, logged-out listing pages, applies conservative rate limits, and stops on any block rather than trying to get around it. What you may do with the data afterwards depends on where you and the businesses are and on how you use it, especially for outreach. This is not legal advice; check the rules that apply to your use before you build on the data.