How to monitor keyword rankings with Python
A minimal rank tracker in Python: query, pull your position from organic_results, store it in SQLite, and the credit math behind tracking depth.

A rank tracker does not need to be a product. For a handful of keywords and one domain, it is a loop, a database file, and a way to spot the day something moved. Here is the whole thing in about sixty lines.
The shape of it
Four steps, run on a schedule: send each keyword through /serp with a fixed country and location, find your domain in organic_results, write the position to SQLite, and compare it to yesterday’s row.
import sqlite3
import requests
from datetime import date
from urllib.parse import urlparse
API_KEY = "faf_live_..."
DB_PATH = "rankings.db"
DOMAIN = "fastaf.co"
COUNTRY = "us"
KEYWORDS = [
"web scraping api",
"serp api pricing",
"google search api alternative",
]
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS rankings (
checked_on TEXT,
keyword TEXT,
domain TEXT,
position INTEGER,
PRIMARY KEY (checked_on, keyword, domain)
)
""")
return conn
Fetching a position
organic_results comes back with a rank field already on each entry, so finding yours is a matter of comparing the hostname of link with the domain, exactly or as a subdomain (a plain domain in link also matches https://example.org/review/fastaf.co). Depth is a loop over page, since Google no longer returns 100 results in one request:
def on_domain(link, domain):
host = urlparse(link).hostname or ""
return host == domain or host.endswith("." + domain)
def find_position(keyword, domain, country=COUNTRY, location=None, max_pages=1):
for page in range(max_pages):
params = {"query": keyword, "country": country, "page": page}
if location:
params["location"] = location
resp = requests.get(
"https://api.fastaf.co/v1/serp",
params=params,
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=60,
)
resp.raise_for_status()
for result in resp.json().get("organic_results", []):
if on_domain(result.get("link", ""), domain):
return result["rank"]
return None # not found within max_pages
location narrows the search to a specific place (a city, for instance) rather than just a country, which matters for anything with local intent. page is the depth control: each page is a separate billable request, ten results at a time.
Tracking a list and saving the day’s run
def track(conn, keywords, domain, max_pages=1):
today = date.today().isoformat()
for keyword in keywords:
position = find_position(keyword, domain, max_pages=max_pages)
conn.execute(
"INSERT OR REPLACE INTO rankings VALUES (?, ?, ?, ?)",
(today, keyword, domain, position),
)
print(f"{keyword}: {position}")
conn.commit()
if __name__ == "__main__":
conn = init_db()
track(conn, KEYWORDS, DOMAIN)
Run this once a day (cron, a scheduled function, whatever you already have) and rankings.db accumulates one row per keyword per day.
Computing the daily delta
The point of storing history is the delta, not the snapshot. Pull each keyword’s last two rows and diff them:
def deltas(conn, domain):
rows = conn.execute("""
SELECT keyword, checked_on, position
FROM rankings
WHERE domain = ?
ORDER BY keyword, checked_on
""", (domain,)).fetchall()
by_keyword = {}
for keyword, checked_on, position in rows:
by_keyword.setdefault(keyword, []).append((checked_on, position))
for keyword, history in by_keyword.items():
if len(history) < 2:
continue
(_, prev), (_, curr) = history[-2], history[-1]
if prev is None or curr is None:
continue
change = prev - curr # positive means it moved up
if change != 0:
print(f"{keyword}: {prev} -> {curr} ({change:+d})")
The credit math behind “depth”
This is the part that quietly wrecks a credit budget if it is not sized up front. A /serp request costs 5 credits regardless of depth, but “top 100” is ten separate requests, not one, since Google no longer honors a single request returning 100 results.
| Setup | Requests/day | Credits/day | Credits (30 days) | Pack that covers it |
|---|---|---|---|---|
| 500 keywords, daily, depth 10 (page 0 only) | 500 | 2,500 | 75,000 | Growth, $29 |
| 500 keywords, daily, depth 100 (10 pages) | 5,000 | 25,000 | 750,000 | Pro, $299 |
Depth 10 checks whether you rank on the first page at all, which is enough for most tracking. Depth 100 is what you need if you also want to know where you sit on pages 2 through 10, and it costs ten times as much for the same keyword list. Set max_pages deliberately, not by habit: a tracker quietly bumped from depth 10 to depth 100 is a 10x credit bill for the same list of keywords.
Checking twice a day instead of once doubles every number in that table; checking weekly instead of daily divides them by seven. Pick a frequency the ranking movement you care about actually needs, not the shortest interval your scheduler supports.
The short version
A rank tracker is a /serp call, a domain match against organic_results, and a SQLite table, nothing more exotic than that. The number that decides your bill is depth times frequency times keyword count, not the tool. Work that out before scheduling the job: pricing has the credit cost per pack, and the docs cover the full set of /serp parameters including location and device.