HasData
Back to all posts

Build Your Own Geo-Grid Local Rank Tracker

A dental practice in midtown Manhattan ranks first for “dentist”. Move the search two kilometres east and it drops out of the results entirely.

That is the number a normal rank tracker reports as position 1. To find out how often it happens, we searched from every point of a 7×7 grid over New York and over Boise, five keywords in each city, 500 searches in total. Averaged over the ten businesses tracked, each of them the top result in a search from the city centre, they appeared in 21% of the New York grid points and 68% of the Boise ones.

Searching from many points and recording the position at each one is what local SEO tools call a geo-grid, and it is the only form of local rank tracking that survives contact with how Maps actually works. This guide covers what those numbers mean for anyone measuring local visibility, then builds a geo-grid tracker in Python that produces them.

Why a Single Local Ranking Does Not Exist

Google Maps weighs three things together, how relevant a business is to the query, how prominent it is, and how far it sits from the searcher. The searcher’s coordinates are an input to the ranking, so a query run from two places is two different queries. The effect is easy to state and rarely quantified, so we measured it.

What Maps ranks is the Google Business Profile behind each listing, so the profile is the unit a geo-grid measures. The method was the same in both cities. Take a keyword, search it from the city centre, and take whichever business ranks first. Then run that same keyword from all 49 points of a grid two kilometres apart, 12 km across, and record where that business lands each time. Matching is by placeId rather than by name or website, so a rebrand or a missing website field cannot break the match.

CityKeywordTop result in the centre searchGrid points where it appearsMedian positionWorst position
New Yorkcoffee shopBird & Branch Coffee Roasters10 of 49416
New YorkdentistExpert Dental Midtown5 of 4917
New YorkplumberPlumbing NYC16 of 49120
New YorkgymPowerhouse Gym W. 27th Street9 of 49218
New YorkpizzaJoe’s Pizza Broadway11 of 4916
Boisecoffee shopSlow by Slow Coffee28 of 49720
BoisedentistCapitol Dental21 of 49220
BoiseplumberFive Star Service Pros Plumbing39 of 49316
BoisegymGrove Fitness Club & Spa32 of 494.520
BoisepizzaThe Front Door Taphouse46 of 49619

The business for each keyword is whichever one came back first in a single search from the city centre. Repeating that same search as part of the grid returned position 1 for six of the ten and position 2 or 3 for the rest, which is the day-to-day churn any grid has to be read against.

One more word in that table needs pinning down. The endpoint returns 20 results per search, so a business counted as absent from a point is outside the top 20 from that point rather than gone from Google. Paging further does reach them, since start=20 returns positions 21 to 40 and start=40 returns 41 to 60, but for a Map Pack that shows three results a position in the forties is the same as absent. Every number here treats the top 20 as the whole ranking.

The dentist row is the one to sit with. Expert Dental appears in 5 of 49 points, and in those five it is usually first. A tracker checking one downtown coordinate would report a perfect position every morning for a business invisible across nine tenths of the area it serves.

Drawn out, the New York coffee shop looks like this.

Heat map of a 7 by 7 grid over New York showing Bird and Branch Coffee Roasters in the top three at the centre, ranked four to sixteen in a narrow ring, and absent from every point beyond about four kilometres

Ten green and amber cells in the middle, grey everywhere else. The same keyword in Boise fills far more of the map.

Heat map of a 7 by 7 grid over Boise showing Slow by Slow Coffee in the top three at the centre, ranked six to twenty across a wide band, and absent from the western third

Both businesses are top results downtown. One of them owns a neighbourhood and the other owns a city.

How Far the Effect Reaches

Two distances matter when reading a grid. The first is how far a business holds a top-three place, which is the radius where it still wins the Map Pack. The second is how violently the position moves between neighbouring points, which decides how fine a grid needs to be.

In New York the top-three radius ran from 2.9 km for the dentist to 4.3 km for the coffee shop. In Boise the same measurement ran from 3.2 km to 8.6 km, and three of the five businesses held a top-three place past eight kilometres. Density explains it. Manhattan has hundreds of coffee shops inside a two kilometre circle, so proximity sorts them and the winner changes street by street. Boise has fewer candidates spread over more ground, so one listing covers most of town.

Across the 330 neighbouring pairs where a business appears at both points, two kilometres apart, the median jump was 1 place and the largest was 19. Pairs where it appears at one point and not the other are left out of that figure, and those pairs are the cliff edge, so the median describes the inside of a catchment rather than the whole map. Read together, the two facts say rankings hold steady across the interior and then collapse at the boundary. A grid is what shows you where the boundary runs.

The other consequence is that comparing “your position” against a competitor’s is meaningless without saying from where. Two businesses can both truthfully claim first place for the same keyword in the same city on the same morning.

How Big the Geo-Grid Should Be

Every extra point is another search, so grid size is a cost decision. Covering one 12 km square takes 9 searches at 6 km spacing and 49 at 2 km, five and a half times as many for the same ground.

That last phrase is the one to hold onto. A smaller grid is only cheaper if the spacing grows to keep the square the same size. Cutting the point count while leaving the spacing alone shrinks the area instead, and a 3×3 at 2 km spacing measures a 4 km square, which is the dense core where any business looks strong.

To find out what the cheaper grids give up, we treated the 49-point sweep as the truth and asked what a single centre check and a 3×3 over the same square would have reported for the same ten businesses.

GridSpacingSearches per keywordCoverage estimate off byWorst position understated by
Centre point onlynone1median 62 points, worst 90median 17 places, worst 19
3×36 km9median 8 points, worst 24median 6.5 places, worst 19
7×72 km49the referencethe reference

A single downtown check is not a cheap version of a grid. It reports the business at or near the top, because that is how the business was chosen, and it never sees the far side of town. Against the full sweep it misjudges coverage by a median of 62 percentage points and understates the worst position a searcher would encounter by a median of 17 places.

The 3×3 row is where the real decision sits, and the average flatters it. Six of the ten businesses came within 11 points of the full sweep’s coverage and the other four missed by 21 to 24, so a 3×3 tells you reliably whether visibility is growing or shrinking and unreliably how wide it is. It is a trend instrument rather than a number to put in front of a client.

The span matters as much as the point count. A 12 km square suited both cities here, and a reasonable rule is roughly twice the distance a customer will travel to reach the business. Dense urban catchments want a smaller square with tighter spacing, and rural ones want the opposite.

Building the Geo-Grid Tracker

The tracker needs three things. Coordinates for each grid point, a Maps search from each of them, and somewhere to put the answer. The examples use the Google Maps Search API, which takes coordinates directly and returns ranked local results as JSON, so the position is a field rather than something to parse out of a page.

Install the one dependency first.

pip install requests

csv, math and datetime ship with Python, so nothing else is needed.

Generating the Geo-Grid Coordinates

You describe the grid in kilometres, and this function hands back the coordinates the API asks for. Everything below stays in kilometres, so the latitude and longitude in the snippets are the output rather than something to work out.

import math

def grid_points(latitude, longitude, size, spacing_km):
    """Coordinates for a size x size grid centred on the business."""
    half = size // 2
    step_lat = spacing_km / 111.32
    step_lng = spacing_km / (111.32 * math.cos(math.radians(latitude)))
    for row in range(size):
        for col in range(size):
            yield (row, col,
                   latitude + (half - row) * step_lat,
                   longitude + (col - half) * step_lng)

One thing happens inside worth knowing about. A degree of longitude covers less ground the further you are from the equator, while a degree of latitude stays the same everywhere, so the same two kilometres east is a different step in Miami than in Anchorage. Grids built on one fixed degree offset come out stretched, wider than they are tall, measuring a rectangle while you think you set a square. The latitude you pass in takes care of it. Set spacing_km and leave the rest alone.

Querying Maps Results per Point

The endpoint takes the coordinate in the ll parameter, in Google’s own @latitude,longitude,zoom format. Everything else is optional.

ParameterWhat it sets
qThe search phrase. Required.
llThe point the search runs from, as @lat,lng,14z
domainWhich Google domain answers, such as google.com
gl, hlCountry and interface language codes
startOffset for paging past the first 20 results

Zoom is part of the coordinate string and worth leaving at 14z across a run, since changing it changes the area Google considers and makes two runs incomparable.

import time
import requests

SEARCH_URL = "https://api.hasdata.com/scrape/google-maps/search"
HEADERS = {"x-api-key": "YOUR-API-KEY"}

def search(keyword, latitude, longitude, retries=4):
    """One Maps search from one point. A 429 means the plan's concurrency limit."""
    for attempt in range(retries):
        response = requests.get(SEARCH_URL, headers=HEADERS, timeout=120, params={
            "q": keyword,
            "ll": f"@{latitude},{longitude},14z",
        })
        if response.status_code == 429:
            time.sleep(5 * (attempt + 1))
            continue
        if response.status_code != 200:
            print(f"  HTTP {response.status_code} at {latitude:.4f},{longitude:.4f}")
            return []
        return response.json().get("localResults", [])
    return []

The retry is not decoration. The free plan allows one request at a time, and a grid is a tight loop of sequential requests, so a slow response and the next call overlapping is the normal failure. Across the 500 searches behind this article the average call took 2.7 seconds and none of them hit a 429, because the loop is sequential by construction.

Matching the Business Reliably

Each entry in localResults carries position, title, placeId, address, rating, reviews, gpsCoordinates and website among others. Matching on placeId is what makes a long-running tracker trustworthy, because names change, chains share them, and the website field is absent for businesses that never filled one in. In 60 results sampled across three searches, one had no website at all.

def position_of(results, place_id):
    """Where the business sits, or None when it is not in the results at all."""
    for result in results:
        if result.get("placeId") == place_id:
            return int(result.get("position", 0)) or None
    return None

Returning None rather than a large number is deliberate. Absent and twentieth are different states, and averaging them together is what produces a tracker that reports a comfortable position for a business nobody can find. Finding a placeId takes one search for the business name near its own address, then reading it off the matching result. That same result carries gpsCoordinates, which is the centre the grid needs, so one search gives you both values and there is no coordinate to look up by hand.

Storing the Results

One row per point per run keeps the history queryable and lets a later run redraw any past grid.

import csv
from datetime import date

def save(rows, csv_file="local_rankings.csv"):
    with open(csv_file, "a", newline="", encoding="utf-8") as file:
        writer = csv.DictWriter(file, fieldnames=list(rows[0]))
        if file.tell() == 0:
            writer.writeheader()
        writer.writerows(rows)

Opening in append mode and writing the header only for a new file means a scheduled run adds to the history instead of replacing it. Checking file.tell() inside the open block is more reliable than testing whether the path exists, since it also covers a file that exists but is empty.

Share of Local Voice

The share of grid points where a business sits in the top three is the metric local SEO tools sell under the name Share of Local Voice. It compresses a grid into one number that is honest, because it carries the geography that a single position hides.

def report(rows):
    found = [r for r in rows if r["position"]]
    top_three = [r for r in found if int(r["position"]) <= 3]
    print(f"appears in {len(found)} of {len(rows)} points")
    print(f"share of local voice: {100 * len(top_three) // len(rows)}% in the top three")

By that measure the New York dentist scores 8% while reporting position 1 downtown, and the Boise pizza place scores far higher on a median position of 6. A business that is second everywhere beats one that is first in a single block, and only the grid shows the difference.

Putting the pieces together gives a script that runs a full grid and appends the result.

import csv
import math
import time
from datetime import date

import requests

API_KEY = "YOUR-API-KEY"
KEYWORD = "coffee shop"
PLACE_ID = "ChIJTVhsxFNYwokRXgPwYnY0vgI"   # Bird & Branch Coffee Roasters
CENTRE = (40.7603, -73.9908)                # the business's own coordinates
GRID_SIZE = 5                               # points per side, so 5 x 5 = 25 searches
SPACING_KM = 2.0                            # kilometres between points
CSV_FILE = "local_rankings.csv"

SEARCH_URL = "https://api.hasdata.com/scrape/google-maps/search"
HEADERS = {"x-api-key": API_KEY}


def grid_points(latitude, longitude, size, spacing_km):
    half = size // 2
    step_lat = spacing_km / 111.32
    step_lng = spacing_km / (111.32 * math.cos(math.radians(latitude)))
    for row in range(size):
        for col in range(size):
            yield (row, col,
                   latitude + (half - row) * step_lat,
                   longitude + (col - half) * step_lng)


def search(keyword, latitude, longitude, retries=4):
    for attempt in range(retries):
        response = requests.get(SEARCH_URL, headers=HEADERS, timeout=120, params={
            "q": keyword,
            "ll": f"@{latitude},{longitude},14z",
        })
        if response.status_code == 429:
            time.sleep(5 * (attempt + 1))
            continue
        if response.status_code != 200:
            print(f"  HTTP {response.status_code} at {latitude:.4f},{longitude:.4f}")
            return []
        return response.json().get("localResults", [])
    return []


def position_of(results, place_id):
    for result in results:
        if result.get("placeId") == place_id:
            return int(result.get("position", 0)) or None
    return None


rows = []
for row, col, latitude, longitude in grid_points(*CENTRE, GRID_SIZE, SPACING_KM):
    results = search(KEYWORD, latitude, longitude)
    position = position_of(results, PLACE_ID)
    rows.append({
        "date": date.today().isoformat(),
        "keyword": KEYWORD,
        "row": row,
        "col": col,
        "latitude": round(latitude, 5),
        "longitude": round(longitude, 5),
        "position": position or "",
        "results_returned": len(results),
    })
    print(f"[{row},{col}] {('#' + str(position)) if position else 'not in results'}")

with open(CSV_FILE, "a", newline="", encoding="utf-8") as file:
    writer = csv.DictWriter(file, fieldnames=list(rows[0]))
    if file.tell() == 0:
        writer.writeheader()
    writer.writerows(rows)

found = [r for r in rows if r["position"]]
top_three = [r for r in found if int(r["position"]) <= 3]
print(f"\nappears in {len(found)} of {len(rows)} points")
print(f"share of local voice: {100 * len(top_three) // len(rows)}% in the top three")

Run as written, this reports the roaster in 7 of the 25 points and a Share of Local Voice of 8%. Raising GRID_SIZE to 7 turns 25 searches into 49, and the spacing wants dropping to 2 km at the same time so the square stays where it was.

Reading a Geo-Grid You Have Just Run

A finished grid answers three questions that a single position cannot.

The first is where the catchment ends. The ring of amber cells around the green core marks the distance at which a business stops winning and starts merely appearing, and that ring is where paid local ads earn their money, because organic proximity has already run out. The edge is ragged rather than circular. In the New York coffee grid a point 4.3 km out still returns a top-three place while another 1.5 km from the door returns position 16.

The second is which direction is weak. The Boise coffee grid holds the top three through a block of cells in the middle, slips to the teens along the eastern edge, and returns nothing at all down the entire western column. An empty flank like that is usually a competitor’s catchment rather than a Google quirk. Running the same grid for whichever business does rank in those cells names the competitor and shows how far their coverage reaches.

The third is whether anything actually changed. This study measured how position varies across space on one morning, which says nothing about how much it drifts at one point over weeks, so setting a noise threshold needs a second run rather than these numbers. What it does show is that coverage counts are the steadier signal. A business that appeared in 10 points last month and 16 this month has gained ground in a way a position readout will not show, particularly when the downtown position sat at 1 the whole time.

Keeping every point in the CSV rather than a daily summary is what makes those comparisons possible later. Summaries throw away the geography, and the geography is the reason for running a grid.

Running It on a Schedule

Local rankings drift slowly, so running the geo-grid weekly captures the trend without burning through searches. Cron handles it on Linux and macOS, and Task Scheduler does the same on Windows.

0 6 * * 1 cd /path/to/tracker && /usr/bin/python3 tracker.py >> tracker.log 2>&1

Appending both streams to a log matters more here than in most scheduled jobs, because a grid that quietly returns empty results looks identical to a business that lost its rankings. The results_returned column in the CSV is the guard against that reading. A row with 20 results and no position is a real absence, while a row with zero results is a failed request.

Multiple keywords and multiple locations multiply out quickly. Five keywords on a 5×5 grid is 125 searches a week for one business, and an agency running ten clients is well into five figures a month. Deciding the grid size before scaling is cheaper than discovering the bill afterwards.

What This Costs to Run

Cost scales with points, keywords, locations and frequency, and nothing else. One Maps search through the Google Maps Search API costs 5 credits, so a 5×5 grid on one keyword is 125 credits per run and the 1,000 credits a new account starts with cover eight of them.

Google’s own Places API is the other route, and its billing changed in a way that matters for grid work. The flat monthly credit that used to cover casual use is gone, replaced by a free cap of 10,000 events a month on each individual SKU, with per-thousand pricing above it. A grid burns those events faster than most uses, since every point is a separate billable search rather than one query returning a page of results.

Either way the arithmetic is the same shape, and it is worth doing before committing to a grid size. Forty-nine points feels free on the first keyword and stops feeling free at the fortieth.

When a Ready-Made Tool Makes More Sense

Building this pays off when the tracker feeds something else, such as a client dashboard, an internal database, or a report that combines rankings with data the ready-made tools never see. It also pays off at agency scale, where per-location pricing on a subscription outruns the cost of the searches themselves.

Buying makes more sense for a single business that wants a picture once a month. The commercial local trackers render the map, store the history, and handle the scheduling, and reproducing that polish is a project rather than an afternoon. For a straightforward one-off check without any code, our no-code Google Maps scraper returns ranked results for a keyword and location, and the Google Sheets rank tracker covers recurring checks from a spreadsheet.

The honest split is that the tools sell the interface and the schedule. The data underneath is a coordinate, a keyword and a position, and that part is thirty lines of Python.

Which Setup to Run

Start with a 3×3 at 6 km spacing on the keywords that actually bring customers, run it weekly, and record every point rather than a summary. Nine searches per keyword track the trend at a fifth of the cost, as long as the square stays fixed and the spacing does the shrinking.

Move to 5×5 or 7×7 for the questions a coarse grid cannot answer, such as which neighbourhoods to open in or where a competitor’s catchment ends. Those are the questions where the edges matter, and on four of the ten businesses here the coarse grid missed the coverage figure by more than 20 points.

Whatever the size, track the share of points in the top three next to the position. A business sitting first in one block and nowhere else is a fragile position that reads as a perfect one, and the whole point of a grid is that it stops hiding.

Valentina Skakun
Valentina Skakun
Valentina is a software engineer who builds data extraction tools before writing about them. With a strong background in Python, she also leverages her experience in JavaScript, PHP, R, and Ruby to reverse-engineer complex web architectures.If data renders in a browser, she will find a way to script its extraction.
Articles

Might Be Interesting