Python Rank Tracker: Track Google Positions in SQLite Without a $99/mo Tool
Rank tracking subscriptions start around $99/month and go up from there, but the mechanical core of the product is small: fetch a SERP, find your domain, store the position, diff it against last time. This post walks through rank_tracker, the SQLite-backed rank tracking CLI in the zens-ink package — add / check / status / trend / history, one Serper.dev API call per keyword per check, storage in a local SQLite file, pure Python stdlib. Real data from two months of tracking zens.ink: the brand term slipped from #1 to #2 behind the product's own storefront page, a same-minute SERP variance incident that showed one snapshot 'not in top 20' and the next three at #2, and a cross-check against Google Search Console's averaged positions that explains why GSC says position 7 while a live anonymous SERP says not-in-top-20. With cost math for weekly checks on a 17-keyword set and the honest limits of point-in-time rank measurement.
TL;DR — A rank tracker needs four things: fetch the SERP, find your domain, store a snapshot, diff it against yesterday. python -m zens_ink.rank_tracker does exactly that in 300 lines of pure stdlib, backed by SQLite at ~/.zens_ink/ranks.db and one Serper.dev call per keyword per check. Two months of real snapshots on zens.ink produced three findings worth more than the dashboard: the brand term dropped #1 → #2 because the product's own storefront page took the top spot, the same keyword returned "not in top 20" and then "#2" in consecutive checks one minute apart, and Google Search Console's averaged position for several quick-win keywords does not match what a live anonymous SERP shows at all. pip install zens-ink, set SERPER_API_KEY, done.
Rank tracker pricing pages are a genre of their own: $99/month to watch a list of keywords. The mechanical core of the product, though, is four steps — fetch a SERP, find your domain in it, store the position, diff it against the last snapshot. That’s a weekend of Python, and the zens-ink package ships it as rank_tracker: SQLite storage, one Serper.dev call per keyword per check, pure standard library.
This post runs it against real data — including two months of snapshots on zens.ink that turned up three things no dashboard would have pointed out: a brand-term takeover by the site’s own storefront, a same-minute SERP variance incident, and a live-versus-GSC position gap that changes how you should read Search Console numbers.
The tool
If you’ve used any of the other zens-ink CLIs, the shape is familiar. Install once, put a Serper key in .env (the free tier grants 2,500 calls/month), and the tracker works offline-first against a local SQLite file at ~/.zens_ink/ranks.db:
pip install zens-ink
# .env: SERPER_API_KEY=... (free: serper.dev)
Six actions, no config file:
python3 -m zens_ink.rank_tracker add "open source seo tools,seo cli tool" --domain zens.ink
python3 -m zens_ink.rank_tracker check # fetch SERPs, write snapshots
python3 -m zens_ink.rank_tracker status # latest position per keyword
python3 -m zens_ink.rank_tracker trend # latest vs previous snapshot
python3 -m zens_ink.rank_tracker history "zensink"
python3 -m zens_ink.rank_tracker remove "old keyword"
Everything after add and remove is read-only against the database until you check again, which is what makes daily snapshots safe: checking is the only action that spends API calls or writes positions.
A real check
Here’s a fresh check on a 17-keyword set (a mix of zens.ink terms and another site’s, tagged separately — domain is bound per keyword, so one database can track several properties):
$ python3 -m zens_ink.rank_tracker check
checked 17/17 keywords:
# — blind signing
# — bluf seo
# — content brief template
# — geo score methodology
...
# — wallet drainer
# 2 zensink
The — is the tracker’s way of saying “domain not in the fetched SERP” — with the default depth of 20 results, that means not in the top 20 for that anonymous US-desktop SERP. Only the brand term zensink came back with a position: #2.
That’s the honest state of a young site’s Google visibility, and it’s worth pausing on: GSC quick-win mining on the same property found queries averaging position 7-21, yet a live anonymous SERP doesn’t surface them at all. More on that gap below — it’s the most useful thing this exercise surfaced.
Finding 1: the brand term slipped, and the winner is us
The history action prints every snapshot ever taken for one keyword. For the brand term, two months of data:
$ python3 -m zens_ink.rank_tracker history "zensink"
history: zensink
2026-10-13T00:11:19Z # 2 https://zens.ink/
2026-10-13T00:08:15Z # —
2026-10-13T00:07:06Z # 2 https://zens.ink/
2026-08-14T18:03:26Z # 1 https://zens.ink/
2026-08-14T17:47:57Z # 1 https://zens.ink/
The story in five rows: in mid-August the homepage held #1 for its own brand term. Two months later it holds #2 — and the current #1 is the product’s own storefront page on the payment provider’s store subdomain. Nobody attacked us; a page about us, on a high-authority domain we don’t control, absorbed the top spot.
For a brand term with navigational intent this costs little traffic — searchers click the result that says zens.ink either way. But it’s the exact pattern to watch on money terms: an aggregator, a marketplace listing, or a comparison page quietly taking the position your page used to hold. A tracker with history catches the day it happened; a glance at “current position: 2” does not.
Finding 2: one keyword, two answers, one minute apart
Look at the middle rows of that history again: at 00:08 the same keyword at the same depth returned not in top 20. At 00:07 and 00:11, #2. I re-ran the fetch three times in a row immediately after and got #2, #2, #2.
So within the span of about four minutes, one anonymous SERP for a stable brand query produced two different answers. Whether that’s API-side snapshot rotation or Google serving different datacenters, the lesson generalizes: a single rank check is a sample, not a measurement. If your tracker reports a violent one-day swing — especially on a stable query — re-check before doing anything. This is also why the tracker appends a snapshot row per check instead of overwriting “current position”: the history is the product. One flaky fetch becomes visible as a lone spike instead of a permanent wrong number.
Commercial trackers paper over this with multi-location averaging. That’s one legitimate thing the $99 buys you. The cheap version is the same discipline the variance tests in our SERP benchmark taught: repeat the sample before you trust it.
Finding 3: GSC position and live position are different measurements
The keyword set I checked includes the exact queries GSC flagged as quick wins on this property — the ones mined at position 5-30 with real impressions. GSC’s three-month averages said: bluf seo ≈ 7, keyword research with python ≈ 21. The live anonymous SERP at depth 20 and depth 50: not found, for either.
Both are true, and the reasons stack:
- Averaging. GSC averages position across every date in the window, every query variant it groups, and every ranking URL on the site. A query where the site held #4 for two weeks and #40 after shows ~22.
- Served versus anonymous. GSC positions come from results Google actually served real searchers — mixed regions, devices, and personalization. The tracker fetches one anonymous US-desktop SERP.
- Recency. GSC’s window lags; a page that slid last week still carries better old positions in the average.
The practical protocol falls out directly: use GSC to decide what’s worth tracking, use live snapshots to watch the specific climb. GSC tells you where impressions and clicks exist; it can’t tell you what an anonymous searcher sees today. The tracker tells you what one anonymous searcher sees today; it can’t tell you whether anyone real ever saw you. The quick-win filter feeding the add command is the intended pipeline: GSC shortlist → live verification → track the promoted pages weekly.
Cost math
Each check spends one Serper call per keyword:
- 17 keywords, weekly: 17 × 4.3 ≈ 74 calls/month — about 3% of the 2,500 free tier
- 50 keywords, daily: ~1,500 calls/month — still inside free, but consider weekly
- 200 keywords, weekly: ~860 calls/month — comfortable; daily would need the paid tier ($1/1k with Serper’s pricing, so even that is under a dollar a month)
Compare: the database is a file you own, the SERP source is swappable behind one function, and there’s no per-seat anything. The thing you give up is infrastructure: no scheduled crawlers (wrap check in cron), no multi-city grids, no PDF reports. If you need those, buy them. If you need positions in a file, this is the whole job.
Reading the trend output
$ python3 -m zens_ink.rank_tracker trend
keyword pos prev Δ
zensink 2 1 -1
The Δ column counts positions gained or lost since the previous snapshot (positive is better — a move from 14 to 9 is +5). The other 16 keywords all read — — 0: never found, nothing to diff. A young site’s trend table is mostly zeros, and that’s fine — the rows that light up are the ones you’re actively promoting, which is exactly the signal you want isolated.
Honest limits
- Top-N blindness. A keyword reads
—past the fetch depth (default 20,--numup to what your API plan returns). There’s no “position 47” — just unknown. For young sites, that’s most keywords, most of the time. - One market per check.
--gl/--hlflags apply to the whole check, defaulting to US/English. Multi-market tracking means separate checks per market, and the snapshots share one table — filter by date if you do this. - Variance, again. Finding 2 is not a corner case; treat any single-check movement you’d act on as unverified until re-checked.
- Not GSC. No click data, no impressions, no query variants. It measures one SERP, not your search presence. The two tools disagree because they measure different things — that’s a feature, if you read both correctly.
- Check frequency is yours. Nothing runs on its own; a cron line makes it daily, and the SQLite history makes any cadence safe.
Wiring it in
The tracker is one node in the package’s workflow: keyword research finds candidates, difficulty scoring filters them, GSC quick wins shortlist what’s already close, and rank_tracker watches whether the promotion actually moved the SERP. It’s also exposed through the same MCP server as every other tool, so an agent can check positions without remembering CLI flags — and everything ships in the open-source package.
Two months of snapshots bought three insights — a brand-term takeover to reverse with internal links, a variance discipline that would have otherwise read as a ranking collapse, and a GSC-versus-live gap that reframed what “position 7” means. That’s a better return than most dashboards I’ve paid for.
FAQ
How much does it cost to run a Python rank tracker?
With Serper.dev's free tier, effectively zero for a personal site. Each check fetches one SERP per keyword, so the cost is keywords-times-checks: a 17-keyword set checked weekly is about 74 API calls a month, roughly 3% of the 2,500 free monthly calls Serper grants. The tracker itself is pure Python standard library with SQLite storage — no database server, no hosting, no subscription. The equivalent commercial rank trackers start around $99/month because they add multi-location tracking, scheduled crawlers, competitor grids, and report PDFs on top of the same mechanical core.
Why track ranks manually when Google Search Console already reports positions?
They measure different things and fail differently. GSC reports the average position across every date in your window, every query variant it groups together, and every ranking URL on your site, for searches Google actually served — it is an averaged, retrospective, blended measurement. A live tracker fetches one anonymous, single-market, desktop SERP right now. In the data for this post, GSC showed a keyword averaging position 7 while the live anonymous SERP did not have the site in the top 20 — both readings were true. Use GSC to find where you have real impressions and clicks; use live snapshots to watch a specific climb you are actively promoting, where GSC's averaging would smear the signal across weeks.
How reliable is a single rank check?
Less reliable than you would hope, and the data in this post quantifies it: the same brand keyword, fetched at the same SERP depth, returned 'not in top 20' once and '#2' three times in consecutive checks within minutes. SERP data served through an API can rotate between snapshots, and Google itself varies results by datacenter and query context. The practical rules: never react to a single violent swing, re-check before believing a drop, and read the snapshot history rather than any single point — which is exactly why the tracker stores every check in SQLite instead of overwriting the last position.
Where is the tracking data stored, and can I query it directly?
In a local SQLite database at ~/.zens_ink/ranks.db, created automatically on first use. Two tables: keywords (keyword, domain, tag, created_at) and snapshots (keyword, position, url, checked_at), with an index on keyword plus timestamp. Because it is plain SQLite, you can open it from Python, point a charting library at it, or back it up by copying the file. Every CLI action also accepts --json if you want to pipe output into other tooling.
Can it track multiple domains or group keywords?
Yes — domain is per keyword, set with --domain when you add each keyword, and an optional --tag groups them (the data in this post mixes a 'demo' tag, an 'ocd' tag for another site, and an 'article' tag). One check fetches SERPs for every tracked keyword and writes one snapshot per keyword, each matched against its own domain. There is no per-city geo tracking — gl and hl are per-check flags defaulting to US/English — so multi-market tracking means running separate checks with different --gl values.
Want to run this analysis on your own site?
ZensInk Pro automates this pipeline. One command, from seed keywords to content plan.
Get Pro →