Build a simple keyword rank tracker
Track where a domain ranks on Google for a keyword list, every day, by country and device. Python, SQLite and the CrawlerData API, with real cost math.
CrawlerData teamPublished 8 min read
Contents
A rank tracker answers one question every day: for each keyword you care about, where does your domain appear on Google? Commercial tools add dashboards, alerts and competitor views on top, but the core is small enough to build yourself in an afternoon: a keyword list, one search per keyword, country and device, and a table of positions over time.
This guide builds that core in Python with the Google Search API and SQLite. By the end you’ll have a script you can run from cron, a history you can query, and a clear idea of what it costs per month.
What we’re building
- A list of keywords and one domain to track.
- A list of markets, each a country and a device (
desktopormobile). - A daily job that searches every keyword in every market and records the domain’s position, the URL that ranked, and two SERP features: whether the domain is cited in the AI Overview, and how many People Also Ask questions were shown.
- A SQLite table with one row per keyword, market and day, so you can compare any day with any other.
You’ll need Python 3.9+, pip install requests, and an API key in the CRAWLERDATA_API_KEY environment variable.
Configuration
import os
import sqlite3
import time
from datetime import date, datetime, timezone
from urllib.parse import urlparse
import requests
API_URL = "https://api.crawlerdata.com/v1/jobs"
HEADERS = {"Authorization": f"Bearer {os.environ['CRAWLERDATA_API_KEY']}"}
DOMAIN = "zapier.com"
KEYWORDS = [
"project management software",
"free project management software",
"kanban board app",
]
MARKETS = [ # (country, device)
("us", "desktop"),
("us", "mobile"),
("gb", "desktop"),
]
country takes one of 31 two-letter codes (us, gb, de, au, jp and so on), and device is desktop or mobile. Every market you add becomes its own ranking series.
Submit every search at once
A daily batch doesn’t need answers in the same HTTP response, so use the fast queue. Each POST returns 202 Accepted with a job ID within a second or so, and the search runs in the background. The queue doesn’t change the price: every request is 1 credit.
def submit(keyword, country, device):
body = {
"endpoint": "google-search",
"params": {"query": keyword, "country": country, "device": device},
"queue": "fast",
}
resp = requests.post(API_URL, json=body, headers=HEADERS, timeout=30)
resp.raise_for_status()
return resp.json()["job"]["id"]
def fetch(job_id):
resp = requests.get(f"{API_URL}/{job_id}", headers=HEADERS, timeout=30)
resp.raise_for_status()
return resp.json()
When we submitted a mobile search this way, the POST came back in half a second with "status": "pending", and the job had completed by the time we polled it. The script never holds a connection open while Google is searched.
Find the domain in the results
organic_results lists the organic results on the page, each with position, title, link, displayed_link and snippet. The link is the destination URL, not a Google redirect, so you can compare hostnames directly:
def matches(link, domain):
host = (urlparse(link).hostname or "").lower()
return host == domain or host.endswith("." + domain)
def find_rank(result, domain):
for r in result["organic_results"]:
if matches(r["link"], domain):
return r["position"], r["link"]
return None, None # not on this page
def cited_in_ai_overview(result, domain):
aio = result.get("ai_overview")
return bool(aio) and any(matches(s["link"], domain) for s in aio["sources"])
Matching on the hostname, including subdomains, means blog.example.com counts for example.com. If you only want one section of a site, match on the URL path as well.
ai_overview is null when Google shows no AI Overview, so the citation check returns False in that case. When there is one, its sources list holds the cited pages, each with a title and link.
Store the history in SQLite
One row per day, keyword, country and device. A NULL position means “not found on page 1”, which is different from a position you failed to collect. Failed jobs simply produce no row.
db = sqlite3.connect("ranks.db")
db.execute("""
CREATE TABLE IF NOT EXISTS rankings (
day TEXT NOT NULL,
keyword TEXT NOT NULL,
country TEXT NOT NULL,
device TEXT NOT NULL,
position INTEGER, -- NULL = not found on page 1
url TEXT,
ai_cited INTEGER NOT NULL,
paa_count INTEGER NOT NULL,
fetched_at TEXT NOT NULL,
PRIMARY KEY (day, keyword, country, device)
)""")
def save(day, keyword, country, device, result):
position, url = find_rank(result, DOMAIN)
db.execute(
"INSERT OR REPLACE INTO rankings VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(day, keyword, country, device, position, url,
int(cited_in_ai_overview(result, DOMAIN)),
len(result["people_also_ask"]),
datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
db.commit()
Storing url as well as position lets you spot cannibalisation: the day a different page of your site starts ranking for the keyword. If you think you’ll want other fields later, save the raw result JSON too. It’s the only way to answer questions you haven’t thought of yet about past days.
The daily run
Submit every job, then poll the pending ones every 15 seconds until they finish or a 30-minute deadline passes:
def run():
day = date.today().isoformat()
pending = {}
for keyword in KEYWORDS:
for country, device in MARKETS:
pending[submit(keyword, country, device)] = (keyword, country, device)
deadline = time.time() + 30 * 60
while pending and time.time() < deadline:
time.sleep(15)
for job_id, (keyword, country, device) in list(pending.items()):
data = fetch(job_id)
status = data["job"]["status"]
if status == "completed":
save(day, keyword, country, device, data["result"])
del pending[job_id]
elif status == "failed":
print("failed:", keyword, country, device, data["job"]["error"])
del pending[job_id]
for keyword, country, device in pending.values():
print("still running, check later:", keyword, country, device)
if __name__ == "__main__":
run()
A failed job has credits set to 0, so a failure costs nothing. You can resubmit it in the same run or let tomorrow’s run fill the gap. With a large keyword list, poll in a gentler loop, or pass a postbackUrl in each request body and receive results by webhook instead.
Schedule it once a day, for example with cron at 06:00:
0 6 * * * cd /opt/rank-tracker && /usr/bin/python3 track.py >> track.log 2>&1
Run it at the same time every day. Rankings move during the day, so a consistent time makes day-to-day changes easier to trust.
What one real check looks like
We ran project management software in the US on both devices in October 2026 and tracked zapier.com. This is what the script stored, selecting keyword, country, device, position, url, ai_cited:
('project management software', 'us', 'desktop', 5, 'https://zapier.com/blog/free-project-management-software/', 1)
('project management software', 'us', 'mobile', None, None, 1)
On desktop, Zapier’s page ranked 5th. On mobile, it was not among the 10 organic results at all, although the mobile AI Overview still cited it. That is one query on one day, so don’t read it as a general rule, but it shows why device has to be part of the key. Averaging the two rows would describe a page nobody saw.
The two pages also differed in size. The desktop page had 7 organic results and the mobile page had 10. A “position 7” on one device isn’t the same place on the page as “position 7” on the other, which is one more reason to report the devices separately.
Report movement
With history in one table, week-on-week change is a self-join:
SELECT t.keyword, t.country, t.device,
t.position AS today, w.position AS week_ago
FROM rankings t
LEFT JOIN rankings w
ON w.keyword = t.keyword AND w.country = t.country AND w.device = t.device
AND w.day = date(t.day, '-7 days')
WHERE t.day = :today
ORDER BY t.keyword, t.country, t.device;
Pass today as the same date.today().isoformat() string the run used. SQLite’s date('now') is UTC, which can disagree with your local date. From here it’s a short step to a CSV for a spreadsheet or a chart of position over time per keyword.
What it costs
Every check is 1 credit: one keyword, in one country, on one device, for one results page. So the monthly number is:
keywords × markets × checks per day × days × pages
Credits come in packs with no subscription, and they’re valid for 6 months from purchase. The ones that expire soonest are used first. The pack prices from the pricing page:
| Pack | Price | Credits | Per 1,000 |
|---|---|---|---|
| Starter | $50 | 50,000 | $1.00 |
| Standard | $375 | 500,000 | $0.75 |
| Scale | $1,250 | 2,500,000 | $0.50 |
| Ultimate | $3,750 | 12,500,000 | $0.30 |
Packs are bought whole, so the cheapest option depends on how many credits you’ll use before they expire. Two worked examples, assuming 30-day months and tracking page 1:
A small site: 100 keywords, desktop and mobile, one country, daily. That’s 100 × 2 × 30 = 6,000 credits a month, or 36,000 over 6 months. One Starter pack ($50, 50,000 credits) covers the full 6 months, and the 14,000 credits left at the end expire. That works out to about $8.33 a month.
An agency client: 500 keywords, desktop and mobile, three countries, daily. That’s 500 × 2 × 3 × 30 = 90,000 credits a month, or 540,000 over 6 months. Buying one pack type over and over:
| Option for 540,000 credits | Packs | Cost | Credits |
|---|---|---|---|
| Starter only | 11 | $550 | 550,000 |
| Standard only | 2 | $750 | 1,000,000 |
| Scale only | 1 | $1,250 | 2,500,000 |
Starter only is the cheapest single-pack option. Mixing pack sizes does better: one Standard plus one Starter is $425 for 550,000 credits, about $70.83 a month. A Scale pack would leave almost 2 million credits to expire unused, which is why the cheapest pack per 1,000 isn’t always the cheapest purchase.
Tracking the top 20 instead of page 1 doubles every figure, because page 2 is a second request. So does checking twice a day. The calculator on the pricing page does this arithmetic for any volume.
Running it in production
- Go deeper than page 1 when you need to.
pagegoes from 1 to 10, at 1 credit per page.positionrestarts at 1 on each page, so add the earlier pages’ result counts when you combine them. - Read trends, not single days. Results are what Google shows for the country, language and device at request time, unpersonalised, which makes them a consistent baseline. Movement over several days tells you more than one reading.
- Record the SERP features too. The same response includes
ads,local_packandtop_stories, which can push organic results down the page. Storing them costs no extra credits. - Use it to measure, not to manipulate. Our Acceptable Use Policy rules out using the data to manipulate rankings, clicks or reviews.
Where to go next
To look at a single search before building anything, our free AI Overview checker runs without an account and shows the AI Overview, its sources and the top results for the country and device you pick.
This tracker is deliberately minimal. Natural next steps are tracking competitor domains from the same searches (no extra credits, as long as you save the raw results), alerting when a keyword drops more than a few places, and adding keywords from Google Autocomplete suggestions. The SEO rank tracking page covers the same use case in more depth, with a worked monthly cost example.
Frequently asked questions
- How many credits does a rank tracker use?
- One credit per keyword, per country, per device, per results page, per check. 100 keywords checked daily on desktop and mobile in one country is 200 credits a day, about 6,000 a month.
- Should I track desktop and mobile separately?
- Yes. Google can show different organic results on each device. In our own comparison of one query, a domain ranked 5th on desktop and was not in the mobile organic results at all. Store device as part of every ranking row.
- Can the tracker see AI Overview citations?
- Yes. When Google shows an AI Overview, the response includes ai_overview with its text and the sources it cites, so the tracker records whether your domain is cited on each device. When there is no AI Overview, the field is null.
- What if my domain is not on page 1?
- The tracker stores no position for that day. You can request pages 2 to 10 with the page parameter, at 1 credit per page, if you need to follow positions further down.
- Which queue should a daily rank tracker use?
- The fast queue. You submit every job at once, get job IDs back straight away and poll for results. It costs the same 1 credit as the live queue.
- Rank tracking
- Google Search API
- SERP API
- Python
- SEO
- Tutorial