DEV Community

Elowen
Elowen

Posted on

Create a SERP Diff Table for Titles, URLs, and Positions

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)