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?
For local SEO, the question changes:
Where does this business appear in Google Maps results for this keyword and city?
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
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
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
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"
}
]
}
Your SERP API provider may use slightly different field names.
Some APIs use:
local_results
maps_results
places
results
For the business name, some use:
title
name
business_name
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
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?
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:
- Reads local SEO keywords from a file
- Reads target businesses from a file
- Calls a Google Maps SERP API
- Extracts local results
- Normalizes business fields
- Matches target businesses by name and website
- Saves a daily ranking snapshot to CSV
- 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,...
That gives you the basic local SEO signal:
For this keyword and city, where does this business appear in Maps?
Install dependencies
Create a project folder and install:
pip install requests python-dotenv pandas rapidfuzz
We will use:
requests → call the SERP API
python-dotenv → load API keys
pandas → save and compare CSV files
rapidfuzz → fuzzy business name matching
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
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
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
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
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
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
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")
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
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()
Depending on your provider, the engine parameter might be different.
You may see values like:
google_maps
google_local
maps
local
Use the value your provider expects.
The important pattern is:
keyword + location → Maps SERP JSON
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 []
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 "",
}
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
}
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
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
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": "",
}
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
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.
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
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
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()
Run it:
python local_maps_rank_tracker.py
You should get a file like:
local_maps_ranking_snapshot_2026-01-01.csv
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,,,,,,
Now you can answer:
Where does the business appear?
Which keywords are missing?
Which city performs better?
Which competitors appear above us?
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()
Run it:
python compare_local_rankings.py
You get:
local_maps_ranking_changes.csv
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
Remember:
Position 3 is better than position 7.
So 7 → 3 means up 4.
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
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
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?
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
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
After creating a DataFrame:
df = pd.DataFrame(rows)
df["visibility_score"] = df["position"].apply(calculate_visibility_score)
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)
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,
)
Then you can query history:
SELECT keyword, location, target_business, date, position
FROM local_maps_rankings
ORDER BY location, keyword, target_business, date;
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
Run daily at 9 AM:
0 9 * * * cd /path/to/project && /usr/bin/python3 local_maps_rank_tracker.py
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
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?
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
Start with:
5 keywords
1 city
1 business
daily CSV snapshot
Then add:
competitors
more locations
visibility scores
SQLite
alerts
dashboards
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)