DEV Community

Cecilia Hill
Cecilia Hill

Posted on

How to Build a Local SEO Rank Tracker with Google Maps SERP Data

Local SEO rank tracking is different from normal Google rank tracking.

For normal SEO, you usually ask:

Where does my website rank for this keyword?
Enter fullscreen mode Exit fullscreen mode

For local SEO, the question changes:

Where does this business appear in Google Maps results for this keyword and city?
Enter fullscreen mode Exit fullscreen mode

That difference matters.

A restaurant, dentist, school, hotel, gym, repair shop, or local service business may not care only about organic links.

They care about Google Maps visibility.

If someone searches:

dentist near me
coffee shop in Austin
best hotel in Chicago
plumber in Brooklyn
private school near Dallas
Enter fullscreen mode Exit fullscreen mode

the Maps results can matter more than the normal organic results.

In this article, we will build a simple local SEO rank tracker using Python and Google Maps SERP data from a SERP API. Start a free trial of SERP API now>>

The workflow looks like this:

keyword + location
→ Google Maps SERP API
→ local results
→ match target business
→ save ranking snapshot
→ compare changes over time
Enter fullscreen mode Exit fullscreen mode

This is not a full SEO platform.

It is a practical starting point.

Small enough to understand. Useful enough to extend.

What we are tracking

For local SEO, we usually care about fields like:

business name
position
address
phone
website
rating
review count
place ID
maps URL
category
Enter fullscreen mode Exit fullscreen mode

A simplified Google Maps SERP result might look like this:

{
  "local_results": [
    {
      "position": 1,
      "title": "Example Dental Clinic",
      "address": "123 Main St, Austin, TX",
      "phone": "+1 555-123-4567",
      "rating": 4.8,
      "reviews": 214,
      "website": "https://exampledental.com",
      "place_id": "abc123"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Your SERP API provider may use slightly different field names.

Some APIs use:

local_results
maps_results
places
results
Enter fullscreen mode Exit fullscreen mode

For the business name, some use:

title
name
business_name
Enter fullscreen mode Exit fullscreen mode

So we will write a parser that is flexible enough to handle common response shapes.

Why use a SERP API?

You can try scraping Google Maps yourself.

For a quick experiment, maybe it works.

For repeated tracking, it gets messy fast.

You need to deal with:

dynamic pages
location simulation
map result layouts
blocked requests
CAPTCHA
pagination
missing fields
different city results
business name variations
Enter fullscreen mode Exit fullscreen mode

A SERP API gives you structured data.

That means your code can focus on the ranking logic:

Does the target business appear?
What position is it?
Which competitor is above it?
Did the ranking change?
Enter fullscreen mode Exit fullscreen mode

That is the useful part.

Nobody wants to spend Tuesday afternoon repairing selectors because a div decided to wear a different hat.

What we will build

We will write a Python script that:

  1. Reads local SEO keywords from a file
  2. Reads target businesses from a file
  3. Calls a Google Maps SERP API
  4. Extracts local results
  5. Normalizes business fields
  6. Matches target businesses by name and website
  7. Saves a daily ranking snapshot to CSV
  8. Compares two snapshots to detect changes

The output will look like this:

date,keyword,location,target_business,found,position,matched_name,website,address,rating,reviews
2026-01-01,dentist near me,Austin TX,Example Dental Clinic,true,3,Example Dental Clinic,https://exampledental.com,...
Enter fullscreen mode Exit fullscreen mode

That gives you the basic local SEO signal:

For this keyword and city, where does this business appear in Maps?
Enter fullscreen mode Exit fullscreen mode

Install dependencies

Create a project folder and install:

pip install requests python-dotenv pandas rapidfuzz
Enter fullscreen mode Exit fullscreen mode

We will use:

requests → call the SERP API
python-dotenv → load API keys
pandas → save and compare CSV files
rapidfuzz → fuzzy business name matching
Enter fullscreen mode Exit fullscreen mode

Why fuzzy matching?

Because business names are not always perfectly consistent.

For example:

Example Dental Clinic
Example Dental
Example Dental Clinic Austin
Example Dental - Downtown Austin
Enter fullscreen mode Exit fullscreen mode

Exact string matching will miss some of these.

Fuzzy matching gives us a more practical first version.

Create a .env file

Create a .env file:

SERP_API_KEY=your_api_key
SERP_API_URL=https://your-serp-api-endpoint.example.com/search
Enter fullscreen mode Exit fullscreen mode

This article uses a generic SERP API request format.

Your provider may use different parameter names.

For example, some providers use:

q
query
engine
location
ll
hl
gl
type
search_type
Enter fullscreen mode Exit fullscreen mode

Adjust the request function to match your provider’s docs.

The rest of the local rank tracking logic stays the same.

Create a keyword file

Create keywords.txt:

dentist near me
best dentist
emergency dentist
teeth whitening
dental clinic
Enter fullscreen mode Exit fullscreen mode

Use real local-intent keywords.

Do not only test clean keywords.

Local SERPs get interesting when the query is messy.

Create a locations file

Create locations.txt:

Austin, TX
Dallas, TX
Houston, TX
San Antonio, TX
Enter fullscreen mode Exit fullscreen mode

You can use cities, regions, or whatever location format your SERP API supports.

Create a target businesses file

Create businesses.csv:

business_name,website
Example Dental Clinic,exampledental.com
Another Dental Group,anotherdental.com
Enter fullscreen mode Exit fullscreen mode

The website field is optional but helpful.

Business name matching can be fuzzy. Website matching is usually more reliable when available.

Step 1: Load settings

Create a file called local_maps_rank_tracker.py.

import os
import re
import time
import requests
import pandas as pd
from datetime import date
from urllib.parse import urlparse
from dotenv import load_dotenv
from rapidfuzz import fuzz


load_dotenv()

SERP_API_KEY = os.getenv("SERP_API_KEY")
SERP_API_URL = os.getenv("SERP_API_URL")


def validate_settings():
    if not SERP_API_KEY:
        raise ValueError("Missing SERP_API_KEY")

    if not SERP_API_URL:
        raise ValueError("Missing SERP_API_URL")
Enter fullscreen mode Exit fullscreen mode

Keep validation boring and early.

A script should complain before it creates a beautiful CSV full of nothing.

Step 2: Load input files

Add helpers for keywords, locations, and target businesses.

def load_lines(filename):
    with open(filename, "r", encoding="utf-8") as file:
        return [
            line.strip()
            for line in file
            if line.strip()
        ]


def load_keywords(filename="keywords.txt"):
    return load_lines(filename)


def load_locations(filename="locations.txt"):
    return load_lines(filename)


def load_businesses(filename="businesses.csv"):
    df = pd.read_csv(filename)

    required_columns = {"business_name"}

    missing_columns = required_columns - set(df.columns)

    if missing_columns:
        raise ValueError(f"Missing columns in businesses.csv: {missing_columns}")

    if "website" not in df.columns:
        df["website"] = ""

    businesses = []

    for _, row in df.iterrows():
        businesses.append({
            "business_name": str(row["business_name"]).strip(),
            "website": str(row.get("website", "")).strip(),
        })

    return businesses
Enter fullscreen mode Exit fullscreen mode

Now your keyword list and business list live outside the code.

That makes the tracker much easier to reuse.

Step 3: Call Google Maps SERP data

Now write a generic request function.

def fetch_google_maps_serp(keyword, location, language="en"):
    params = {
        "api_key": SERP_API_KEY,
        "engine": "google_maps",
        "q": keyword,
        "location": location,
        "language": language,
        "output": "json",
    }

    response = requests.get(
        SERP_API_URL,
        params=params,
        timeout=30,
    )

    response.raise_for_status()
    return response.json()
Enter fullscreen mode Exit fullscreen mode

Depending on your provider, the engine parameter might be different.

You may see values like:

google_maps
google_local
maps
local
Enter fullscreen mode Exit fullscreen mode

Use the value your provider expects.

The important pattern is:

keyword + location → Maps SERP JSON
Enter fullscreen mode Exit fullscreen mode

Step 4: Extract local results

Different APIs may return local or Maps results under different keys.

Let’s support several common names.

def get_local_items(data):
    possible_keys = [
        "local_results",
        "maps_results",
        "places",
        "place_results",
        "results",
    ]

    for key in possible_keys:
        value = data.get(key)

        if isinstance(value, list):
            return value

    serp = data.get("serp", {})

    if isinstance(serp, dict):
        for key in possible_keys:
            value = serp.get(key)

            if isinstance(value, list):
                return value

    return []
Enter fullscreen mode Exit fullscreen mode

This parser is defensive.

That matters because SERP API response shapes are cousins, not twins.

Step 5: Normalize local result fields

Now normalize each local result into one clean format.

def clean_text(value):
    if value is None:
        return ""

    value = str(value)
    value = re.sub(r"\s+", " ", value)
    return value.strip()


def normalize_website(value):
    if not value:
        return ""

    value = str(value).strip()

    if not value.startswith("http://") and not value.startswith("https://"):
        value = "https://" + value

    parsed = urlparse(value)
    domain = parsed.netloc.lower()

    if domain.startswith("www."):
        domain = domain[4:]

    return domain


def normalize_local_result(item):
    title = (
        item.get("title")
        or item.get("name")
        or item.get("business_name")
        or ""
    )

    website = (
        item.get("website")
        or item.get("website_url")
        or item.get("link")
        or item.get("url")
        or ""
    )

    return {
        "position": item.get("position") or item.get("rank") or "",
        "name": clean_text(title),
        "address": clean_text(item.get("address") or item.get("formatted_address") or ""),
        "phone": clean_text(item.get("phone") or item.get("phone_number") or ""),
        "website": normalize_website(website),
        "rating": item.get("rating") or item.get("stars") or "",
        "reviews": item.get("reviews") or item.get("review_count") or "",
        "category": clean_text(item.get("category") or item.get("type") or ""),
        "place_id": clean_text(item.get("place_id") or item.get("data_id") or ""),
        "maps_url": item.get("maps_url") or item.get("link") or "",
    }
Enter fullscreen mode Exit fullscreen mode

Now the rest of your script can work with a stable shape:

{
  "position": 1,
  "name": "Example Dental Clinic",
  "address": "123 Main St, Austin, TX",
  "website": "exampledental.com",
  "rating": 4.8,
  "reviews": 214
}
Enter fullscreen mode Exit fullscreen mode

This is where the tracker starts becoming useful.

Step 6: Match a target business

Local business matching is not always clean.

I like using two signals:

website domain match
business name fuzzy match
Enter fullscreen mode Exit fullscreen mode

Website matching is more reliable.

Name matching helps when the website field is missing.

def normalize_name(value):
    value = clean_text(value).lower()
    value = re.sub(r"[^a-z0-9\s]", "", value)
    value = re.sub(r"\s+", " ", value)
    return value.strip()


def website_matches(result_website, target_website):
    result_domain = normalize_website(result_website)
    target_domain = normalize_website(target_website)

    if not result_domain or not target_domain:
        return False

    return (
        result_domain == target_domain
        or result_domain.endswith("." + target_domain)
    )


def name_matches(result_name, target_name, threshold=85):
    result_key = normalize_name(result_name)
    target_key = normalize_name(target_name)

    if not result_key or not target_key:
        return False

    score = fuzz.token_set_ratio(result_key, target_key)

    return score >= threshold
Enter fullscreen mode Exit fullscreen mode

The fuzzy threshold is adjustable.

For strict matching, use 90 or higher.

For messier local data, 80 to 85 may work better.

Do not blindly trust fuzzy matching. Always inspect the first few outputs like a suspicious raccoon with a flashlight.

Step 7: Find a business in Maps results

Now find whether a target business appears in the local results.

def find_business_ranking(local_results, target_business):
    target_name = target_business["business_name"]
    target_website = target_business.get("website", "")

    for result in local_results:
        website_match = website_matches(
            result_website=result["website"],
            target_website=target_website,
        )

        name_match = name_matches(
            result_name=result["name"],
            target_name=target_name,
        )

        if website_match or name_match:
            return {
                "found": True,
                "position": result["position"],
                "matched_name": result["name"],
                "matched_website": result["website"],
                "address": result["address"],
                "phone": result["phone"],
                "rating": result["rating"],
                "reviews": result["reviews"],
                "category": result["category"],
                "place_id": result["place_id"],
                "maps_url": result["maps_url"],
                "match_type": "website" if website_match else "name",
            }

    return {
        "found": False,
        "position": "",
        "matched_name": "",
        "matched_website": "",
        "address": "",
        "phone": "",
        "rating": "",
        "reviews": "",
        "category": "",
        "place_id": "",
        "maps_url": "",
        "match_type": "",
    }
Enter fullscreen mode Exit fullscreen mode

This checks each Maps result and returns the first match.

For local SEO, that first match is usually the ranking position you care about.

Step 8: Track one keyword and location

Now combine everything.

def track_keyword_location(keyword, location, businesses, language="en"):
    data = fetch_google_maps_serp(
        keyword=keyword,
        location=location,
        language=language,
    )

    local_items = get_local_items(data)

    local_results = [
        normalize_local_result(item)
        for item in local_items
    ]

    rows = []

    for business in businesses:
        ranking = find_business_ranking(
            local_results=local_results,
            target_business=business,
        )

        rows.append({
            "date": date.today().isoformat(),
            "keyword": keyword,
            "location": location,
            "language": language,
            "target_business": business["business_name"],
            "target_website": business.get("website", ""),
            "found": ranking["found"],
            "position": ranking["position"],
            "matched_name": ranking["matched_name"],
            "matched_website": ranking["matched_website"],
            "address": ranking["address"],
            "phone": ranking["phone"],
            "rating": ranking["rating"],
            "reviews": ranking["reviews"],
            "category": ranking["category"],
            "place_id": ranking["place_id"],
            "maps_url": ranking["maps_url"],
            "match_type": ranking["match_type"],
            "local_result_count": len(local_results),
        })

    return rows
Enter fullscreen mode Exit fullscreen mode

Notice the efficiency detail:

We call the SERP API once for a keyword and location.
Then we check all target businesses against the same result set.
Enter fullscreen mode Exit fullscreen mode

Do not call the API once per business if you do not need to.

That is how budgets quietly leak.

Step 9: Track everything

Now loop through keywords and locations.

def track_all(keywords, locations, businesses, language="en", delay=1):
    all_rows = []

    for location in locations:
        for keyword in keywords:
            print(f"Tracking: {keyword} | {location}")

            try:
                rows = track_keyword_location(
                    keyword=keyword,
                    location=location,
                    businesses=businesses,
                    language=language,
                )

                all_rows.extend(rows)

            except Exception as exc:
                print(f"Failed: {keyword} | {location}")
                print(f"Error: {exc}")

                for business in businesses:
                    all_rows.append({
                        "date": date.today().isoformat(),
                        "keyword": keyword,
                        "location": location,
                        "language": language,
                        "target_business": business["business_name"],
                        "target_website": business.get("website", ""),
                        "found": False,
                        "position": "",
                        "matched_name": "",
                        "matched_website": "",
                        "address": "",
                        "phone": "",
                        "rating": "",
                        "reviews": "",
                        "category": "",
                        "place_id": "",
                        "maps_url": "",
                        "match_type": "",
                        "local_result_count": 0,
                        "error": str(exc),
                    })

            time.sleep(delay)

    return all_rows
Enter fullscreen mode Exit fullscreen mode

The delay is intentional.

Respect rate limits.

Automation should be steady, not a caffeinated woodpecker.

Step 10: Save a daily snapshot

Save the results to CSV.

def save_snapshot(rows):
    today = date.today().isoformat()
    filename = f"local_maps_ranking_snapshot_{today}.csv"

    df = pd.DataFrame(rows)
    df.to_csv(filename, index=False)

    print(f"Saved snapshot: {filename}")

    return filename
Enter fullscreen mode Exit fullscreen mode

Now each run creates a local ranking snapshot.

Full script

Here is the complete first version.

import os
import re
import time
import requests
import pandas as pd
from datetime import date
from urllib.parse import urlparse
from dotenv import load_dotenv
from rapidfuzz import fuzz


load_dotenv()

SERP_API_KEY = os.getenv("SERP_API_KEY")
SERP_API_URL = os.getenv("SERP_API_URL")


def validate_settings():
    if not SERP_API_KEY:
        raise ValueError("Missing SERP_API_KEY")

    if not SERP_API_URL:
        raise ValueError("Missing SERP_API_URL")


def load_lines(filename):
    with open(filename, "r", encoding="utf-8") as file:
        return [
            line.strip()
            for line in file
            if line.strip()
        ]


def load_keywords(filename="keywords.txt"):
    return load_lines(filename)


def load_locations(filename="locations.txt"):
    return load_lines(filename)


def load_businesses(filename="businesses.csv"):
    df = pd.read_csv(filename)

    required_columns = {"business_name"}

    missing_columns = required_columns - set(df.columns)

    if missing_columns:
        raise ValueError(f"Missing columns in businesses.csv: {missing_columns}")

    if "website" not in df.columns:
        df["website"] = ""

    businesses = []

    for _, row in df.iterrows():
        businesses.append({
            "business_name": str(row["business_name"]).strip(),
            "website": str(row.get("website", "")).strip(),
        })

    return businesses


def fetch_google_maps_serp(keyword, location, language="en"):
    params = {
        "api_key": SERP_API_KEY,
        "engine": "google_maps",
        "q": keyword,
        "location": location,
        "language": language,
        "output": "json",
    }

    response = requests.get(
        SERP_API_URL,
        params=params,
        timeout=30,
    )

    response.raise_for_status()
    return response.json()


def get_local_items(data):
    possible_keys = [
        "local_results",
        "maps_results",
        "places",
        "place_results",
        "results",
    ]

    for key in possible_keys:
        value = data.get(key)

        if isinstance(value, list):
            return value

    serp = data.get("serp", {})

    if isinstance(serp, dict):
        for key in possible_keys:
            value = serp.get(key)

            if isinstance(value, list):
                return value

    return []


def clean_text(value):
    if value is None:
        return ""

    value = str(value)
    value = re.sub(r"\s+", " ", value)
    return value.strip()


def normalize_website(value):
    if not value:
        return ""

    value = str(value).strip()

    if not value.startswith("http://") and not value.startswith("https://"):
        value = "https://" + value

    parsed = urlparse(value)
    domain = parsed.netloc.lower()

    if domain.startswith("www."):
        domain = domain[4:]

    return domain


def normalize_local_result(item):
    title = (
        item.get("title")
        or item.get("name")
        or item.get("business_name")
        or ""
    )

    website = (
        item.get("website")
        or item.get("website_url")
        or item.get("link")
        or item.get("url")
        or ""
    )

    return {
        "position": item.get("position") or item.get("rank") or "",
        "name": clean_text(title),
        "address": clean_text(item.get("address") or item.get("formatted_address") or ""),
        "phone": clean_text(item.get("phone") or item.get("phone_number") or ""),
        "website": normalize_website(website),
        "rating": item.get("rating") or item.get("stars") or "",
        "reviews": item.get("reviews") or item.get("review_count") or "",
        "category": clean_text(item.get("category") or item.get("type") or ""),
        "place_id": clean_text(item.get("place_id") or item.get("data_id") or ""),
        "maps_url": item.get("maps_url") or item.get("link") or "",
    }


def normalize_name(value):
    value = clean_text(value).lower()
    value = re.sub(r"[^a-z0-9\s]", "", value)
    value = re.sub(r"\s+", " ", value)
    return value.strip()


def website_matches(result_website, target_website):
    result_domain = normalize_website(result_website)
    target_domain = normalize_website(target_website)

    if not result_domain or not target_domain:
        return False

    return (
        result_domain == target_domain
        or result_domain.endswith("." + target_domain)
    )


def name_matches(result_name, target_name, threshold=85):
    result_key = normalize_name(result_name)
    target_key = normalize_name(target_name)

    if not result_key or not target_key:
        return False

    score = fuzz.token_set_ratio(result_key, target_key)

    return score >= threshold


def find_business_ranking(local_results, target_business):
    target_name = target_business["business_name"]
    target_website = target_business.get("website", "")

    for result in local_results:
        website_match = website_matches(
            result_website=result["website"],
            target_website=target_website,
        )

        name_match = name_matches(
            result_name=result["name"],
            target_name=target_name,
        )

        if website_match or name_match:
            return {
                "found": True,
                "position": result["position"],
                "matched_name": result["name"],
                "matched_website": result["website"],
                "address": result["address"],
                "phone": result["phone"],
                "rating": result["rating"],
                "reviews": result["reviews"],
                "category": result["category"],
                "place_id": result["place_id"],
                "maps_url": result["maps_url"],
                "match_type": "website" if website_match else "name",
            }

    return {
        "found": False,
        "position": "",
        "matched_name": "",
        "matched_website": "",
        "address": "",
        "phone": "",
        "rating": "",
        "reviews": "",
        "category": "",
        "place_id": "",
        "maps_url": "",
        "match_type": "",
    }


def track_keyword_location(keyword, location, businesses, language="en"):
    data = fetch_google_maps_serp(
        keyword=keyword,
        location=location,
        language=language,
    )

    local_items = get_local_items(data)

    local_results = [
        normalize_local_result(item)
        for item in local_items
    ]

    rows = []

    for business in businesses:
        ranking = find_business_ranking(
            local_results=local_results,
            target_business=business,
        )

        rows.append({
            "date": date.today().isoformat(),
            "keyword": keyword,
            "location": location,
            "language": language,
            "target_business": business["business_name"],
            "target_website": business.get("website", ""),
            "found": ranking["found"],
            "position": ranking["position"],
            "matched_name": ranking["matched_name"],
            "matched_website": ranking["matched_website"],
            "address": ranking["address"],
            "phone": ranking["phone"],
            "rating": ranking["rating"],
            "reviews": ranking["reviews"],
            "category": ranking["category"],
            "place_id": ranking["place_id"],
            "maps_url": ranking["maps_url"],
            "match_type": ranking["match_type"],
            "local_result_count": len(local_results),
        })

    return rows


def track_all(keywords, locations, businesses, language="en", delay=1):
    all_rows = []

    for location in locations:
        for keyword in keywords:
            print(f"Tracking: {keyword} | {location}")

            try:
                rows = track_keyword_location(
                    keyword=keyword,
                    location=location,
                    businesses=businesses,
                    language=language,
                )

                all_rows.extend(rows)

            except Exception as exc:
                print(f"Failed: {keyword} | {location}")
                print(f"Error: {exc}")

                for business in businesses:
                    all_rows.append({
                        "date": date.today().isoformat(),
                        "keyword": keyword,
                        "location": location,
                        "language": language,
                        "target_business": business["business_name"],
                        "target_website": business.get("website", ""),
                        "found": False,
                        "position": "",
                        "matched_name": "",
                        "matched_website": "",
                        "address": "",
                        "phone": "",
                        "rating": "",
                        "reviews": "",
                        "category": "",
                        "place_id": "",
                        "maps_url": "",
                        "match_type": "",
                        "local_result_count": 0,
                        "error": str(exc),
                    })

            time.sleep(delay)

    return all_rows


def save_snapshot(rows):
    today = date.today().isoformat()
    filename = f"local_maps_ranking_snapshot_{today}.csv"

    df = pd.DataFrame(rows)
    df.to_csv(filename, index=False)

    print(f"Saved snapshot: {filename}")

    return filename


def main():
    validate_settings()

    keywords = load_keywords("keywords.txt")
    locations = load_locations("locations.txt")
    businesses = load_businesses("businesses.csv")

    rows = track_all(
        keywords=keywords,
        locations=locations,
        businesses=businesses,
        language="en",
        delay=1,
    )

    save_snapshot(rows)

    print(f"Tracked {len(rows)} local ranking rows.")


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it:

python local_maps_rank_tracker.py
Enter fullscreen mode Exit fullscreen mode

You should get a file like:

local_maps_ranking_snapshot_2026-01-01.csv
Enter fullscreen mode Exit fullscreen mode

Example output

The CSV might look like this:

date,keyword,location,target_business,found,position,matched_name,matched_website,address,rating,reviews
2026-01-01,dentist near me,Austin TX,Example Dental Clinic,true,3,Example Dental Clinic,exampledental.com,123 Main St,4.8,214
2026-01-01,best dentist,Austin TX,Example Dental Clinic,false,,,,,,
Enter fullscreen mode Exit fullscreen mode

Now you can answer:

Where does the business appear?
Which keywords are missing?
Which city performs better?
Which competitors appear above us?
Enter fullscreen mode Exit fullscreen mode

Compare two snapshots

Tracking becomes useful when you compare snapshots over time.

Create compare_local_rankings.py.

import pandas as pd


def normalize_position(value):
    if pd.isna(value) or value == "":
        return None

    try:
        return int(value)
    except ValueError:
        return None


def compare_snapshots(old_file, new_file):
    old_df = pd.read_csv(old_file)
    new_df = pd.read_csv(new_file)

    merge_keys = [
        "keyword",
        "location",
        "language",
        "target_business",
        "target_website",
    ]

    merged = new_df.merge(
        old_df,
        on=merge_keys,
        how="left",
        suffixes=("_new", "_old"),
    )

    rows = []

    for _, row in merged.iterrows():
        old_position = normalize_position(row.get("position_old"))
        new_position = normalize_position(row.get("position_new"))

        if old_position is None and new_position is None:
            change_type = "not_found"
            position_change = ""

        elif old_position is None and new_position is not None:
            change_type = "new_ranking"
            position_change = ""

        elif old_position is not None and new_position is None:
            change_type = "lost_ranking"
            position_change = ""

        else:
            position_change = old_position - new_position

            if position_change > 0:
                change_type = "up"
            elif position_change < 0:
                change_type = "down"
            else:
                change_type = "same"

        rows.append({
            "keyword": row["keyword"],
            "location": row["location"],
            "language": row["language"],
            "target_business": row["target_business"],
            "target_website": row["target_website"],
            "old_position": old_position,
            "new_position": new_position,
            "position_change": position_change,
            "change_type": change_type,
            "old_matched_name": row.get("matched_name_old", ""),
            "new_matched_name": row.get("matched_name_new", ""),
            "old_address": row.get("address_old", ""),
            "new_address": row.get("address_new", ""),
        })

    return pd.DataFrame(rows)


def main():
    old_file = "local_maps_ranking_snapshot_2026-01-01.csv"
    new_file = "local_maps_ranking_snapshot_2026-01-08.csv"

    comparison = compare_snapshots(old_file, new_file)

    comparison.to_csv("local_maps_ranking_changes.csv", index=False)

    print(comparison)


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run it:

python compare_local_rankings.py
Enter fullscreen mode Exit fullscreen mode

You get:

local_maps_ranking_changes.csv
Enter fullscreen mode Exit fullscreen mode

With rows like:

keyword,location,old_position,new_position,position_change,change_type
dentist near me,Austin TX,7,3,4,up
best dentist,Austin TX,,5,,new_ranking
emergency dentist,Austin TX,4,,,lost_ranking
Enter fullscreen mode Exit fullscreen mode

Remember:

Position 3 is better than position 7.
So 7 → 3 means up 4.
Enter fullscreen mode Exit fullscreen mode

Lower number means better Maps visibility.

Track competitors too

A local SEO tracker becomes more useful when you track competitors.

Add competitor businesses to businesses.csv:

business_name,website
Example Dental Clinic,exampledental.com
Competitor Dental Group,competitordental.com
Austin Smile Center,austinsmilecenter.com
Enter fullscreen mode Exit fullscreen mode

The script already supports multiple businesses.

For each keyword and location, it will check all businesses against the same Maps result set.

That lets you build a visibility view:

keyword
location
business
position
rating
reviews
Enter fullscreen mode Exit fullscreen mode

Now you can ask:

Which competitors show up most often?
Who ranks above us?
Do higher-rated businesses rank better?
Are review counts related to visibility?
Which city is weakest?
Enter fullscreen mode Exit fullscreen mode

That is where local tracking becomes more than a position checker.

Add a simple visibility score

Position is useful, but a summary score helps.

Here is one simple scoring rule:

position 1 = 10 points
position 2 = 9 points
position 3 = 8 points
...
position 10 = 1 point
not found = 0 points
Enter fullscreen mode Exit fullscreen mode

Add this helper:

def calculate_visibility_score(position):
    try:
        position = int(position)
    except (ValueError, TypeError):
        return 0

    if position < 1 or position > 10:
        return 0

    return 11 - position
Enter fullscreen mode Exit fullscreen mode

After creating a DataFrame:

df = pd.DataFrame(rows)
df["visibility_score"] = df["position"].apply(calculate_visibility_score)
Enter fullscreen mode Exit fullscreen mode

Then summarize:

summary = (
    df.groupby(["target_business", "location"])["visibility_score"]
    .sum()
    .reset_index()
    .sort_values("visibility_score", ascending=False)
)

summary.to_csv("local_visibility_summary.csv", index=False)

print(summary)
Enter fullscreen mode Exit fullscreen mode

This gives a quick local visibility leaderboard.

Not perfect. Very useful.

Save to SQLite instead of CSV

CSV is fine for the first version.

If you run this daily, SQLite is cleaner.

import sqlite3


def save_to_sqlite(rows, database="local_maps_rankings.db"):
    df = pd.DataFrame(rows)

    with sqlite3.connect(database) as connection:
        df.to_sql(
            "local_maps_rankings",
            connection,
            if_exists="append",
            index=False,
        )
Enter fullscreen mode Exit fullscreen mode

Then you can query history:

SELECT keyword, location, target_business, date, position
FROM local_maps_rankings
ORDER BY location, keyword, target_business, date;
Enter fullscreen mode Exit fullscreen mode

This turns the script into a small local SEO database.

Still simple. Much more useful.

Run it on a schedule

On macOS or Linux, use cron.

crontab -e
Enter fullscreen mode Exit fullscreen mode

Run daily at 9 AM:

0 9 * * * cd /path/to/project && /usr/bin/python3 local_maps_rank_tracker.py
Enter fullscreen mode Exit fullscreen mode

On Windows, use Task Scheduler.

Daily is usually enough.

Local rankings move, but checking too often can create noisy reporting and unnecessary API usage.

Things to watch out for

Business name matching can be wrong

Fuzzy matching is helpful, but not magic.

Always inspect matches, especially early on.

If your SERP API returns place_id, use that when possible. A stable place ID is better than fuzzy name matching.

Local results vary by exact location

“Austin, TX” and a specific ZIP code may produce different results.

Use the most precise location your provider supports.

Position is not the whole story

Local visibility also depends on:

rating
review count
category
distance
relevance
business profile completeness
search intent
Enter fullscreen mode Exit fullscreen mode

This script tracks position. It does not explain every ranking factor.

Not found does not always mean gone

A business may be outside the returned result limit.

Or the API may return fewer local results for that query.

Keep local_result_count so you know what happened.

Do not compare different locations as if they are the same

A business ranking 2 in Dallas and 8 in Austin tells a local story.

Do not average those numbers blindly unless your reporting logic is clear.

Provider note

Most SERP API providers return Maps or local search data in slightly different shapes.

When choosing one, test your real queries and check:

Does it return Google Maps results?
Does it include business name, position, address, rating, reviews, and website?
Does it support your target locations?
Does it return enough local results?
Is place_id available?
Is the JSON easy to normalize?
How often are results empty?
Enter fullscreen mode Exit fullscreen mode

For local SEO, clean Maps data matters more than a polished landing page.

Final thoughts

A local SEO rank tracker does not need to start as a big platform.

The core loop is simple:

keywords
→ locations
→ Google Maps SERP data
→ business matching
→ ranking snapshot
→ comparison over time
Enter fullscreen mode Exit fullscreen mode

Start with:

5 keywords
1 city
1 business
daily CSV snapshot
Enter fullscreen mode Exit fullscreen mode

Then add:

competitors
more locations
visibility scores
SQLite
alerts
dashboards
Enter fullscreen mode Exit fullscreen mode

The first version should be boring and easy to debug.

That is a compliment.

Boring scripts are the ones that keep running while you sleep.

Top comments (0)