A SERP diff is only useful if someone can read it.
It is easy to compare two JSON snapshots and produce a long list of changed objects. It is harder to turn those changes into a table that an SEO or growth teammate can review quickly.
This post shows a small Python pattern for creating a readable SERP diff table for URL, title, and position changes.
The search result snapshots can come from a SERP API such as TalorData SERP API. The diff table is the layer that turns raw changes into reviewable output.
The input shape
Assume each snapshot has normalized organic results:
{
"keyword": "serp api for seo monitoring",
"checked_at": "2026-07-09T09:00:00Z",
"results": [
{
"position": 1,
"title": "Example result title",
"url": "https://example.com/page"
}
]
}
The exact provider response does not matter for this step. Normalize first, then diff.
What the table should show
A first diff table can include:
- keyword
- change type
- URL
- previous position
- current position
- previous title
- current title
- short note
That gives reviewers enough context without asking them to open raw JSON.
Example output:
keyword,change_type,url,previous_position,current_position,note
serp api for seo monitoring,position_changed,example.com/page,3,7,URL moved down
serp api for seo monitoring,title_changed,example.com/page,3,3,Title changed
serp api for seo monitoring,new_url,newsite.com/post,,5,New URL entered tracked range
Build the diff rows
from dataclasses import dataclass
from typing import Any
@dataclass
class DiffRow:
keyword: str
change_type: str
url: str
previous_position: int | None
current_position: int | None
previous_title: str | None
current_title: str | None
note: str
def index_by_url(snapshot: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {
result["url"]: result
for result in snapshot.get("results", [])
if result.get("url")
}
Now compare two snapshots:
def build_diff_rows(before: dict[str, Any], after: dict[str, Any]) -> list[DiffRow]:
keyword = after.get("keyword") or before.get("keyword") or "unknown"
before_by_url = index_by_url(before)
after_by_url = index_by_url(after)
rows: list[DiffRow] = []
for url, current in after_by_url.items():
previous = before_by_url.get(url)
if previous is None:
rows.append(
DiffRow(
keyword=keyword,
change_type="new_url",
url=url,
previous_position=None,
current_position=current.get("position"),
previous_title=None,
current_title=current.get("title"),
note="New URL entered tracked range",
)
)
continue
if previous.get("position") != current.get("position"):
rows.append(
DiffRow(
keyword=keyword,
change_type="position_changed",
url=url,
previous_position=previous.get("position"),
current_position=current.get("position"),
previous_title=previous.get("title"),
current_title=current.get("title"),
note="Position changed",
)
)
if previous.get("title") != current.get("title"):
rows.append(
DiffRow(
keyword=keyword,
change_type="title_changed",
url=url,
previous_position=previous.get("position"),
current_position=current.get("position"),
previous_title=previous.get("title"),
current_title=current.get("title"),
note="Title changed",
)
)
for url, previous in before_by_url.items():
if url not in after_by_url:
rows.append(
DiffRow(
keyword=keyword,
change_type="removed_url",
url=url,
previous_position=previous.get("position"),
current_position=None,
previous_title=previous.get("title"),
current_title=None,
note="URL left tracked range",
)
)
return rows
Write the table to CSV
import csv
from dataclasses import asdict
from pathlib import Path
def write_diff_csv(rows: list[DiffRow], output_path: str = "serp_diff.csv") -> None:
fieldnames = [
"keyword",
"change_type",
"url",
"previous_position",
"current_position",
"previous_title",
"current_title",
"note",
]
with Path(output_path).open("w", newline="", encoding="utf-8") as file:
writer = csv.DictWriter(file, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow(asdict(row))
The table is simple enough to attach to a weekly report or paste into a spreadsheet.
Add review labels before alerts
Do not send every diff as an alert.
Add review labels first:
def review_label(row: DiffRow) -> str:
if row.change_type == "removed_url":
return "review"
if row.change_type == "new_url" and row.current_position and row.current_position <= 5:
return "review"
if row.change_type == "position_changed":
if row.previous_position and row.current_position:
movement = abs(row.previous_position - row.current_position)
if movement >= 3:
return "review"
return "log"
This separates logging from alerting.
A diff table should help humans review changes. It should not become a notification machine by default.
Where the SERP API fits
The SERP API provides structured snapshots. The diff table makes those snapshots usable.
A tool like TalorData can provide the recurring search result data, while your code decides how to compare, label, and present changes.
Final note
If you were reviewing SERP diffs, what output would be easiest to use: CSV, Markdown table, Slack message, or a dashboard row?
Top comments (0)