Automate Content Decay Detection with Python and Search Console: Find the Posts Quietly Losing Traffic
New content and algorithm updates get all the attention, but the biggest silent leak on a mature site is content decay: pages that quietly bleed clicks over months without any single dramatic drop. Nobody notices, because on any given week the loss is tiny. Add it up across a hundred posts and it is often the difference between a site that compounds and one that flatlines.
This is a build guide for a Python pipeline that pulls Google Search Console data, compares rolling windows, and flags the URLs that are decaying before they fall off page one. The angle here is deliberate: this is not another “traffic dropped, blame the update” script. Decay and algorithmic hits look different in the data, and treating them the same wastes your time. This pipeline is built to tell them apart and to rank what is worth fixing by lost-click potential, not by raw percentage.
Who this is for (and who it is not)
This pipeline earns its keep on sites with at least 80–100 indexed URLs and roughly six months of Search Console history. Below that, you do not have enough signal: a post with 12 clicks a month can swing 40% on noise alone, and you will chase ghosts. It is also not the right tool if your traffic is highly seasonal and you have less than a full year of data to model that seasonality — you will flag every post that dips after its peak.
If you run a content site, an affiliate portfolio, or a blog with a real back catalog, this is exactly the kind of maintenance work that automation was made for. The pages that decay are rarely your newest ones; they are the two-year-old posts everyone has forgotten.
What “decay” actually looks like in the data
Before writing any code, it helps to separate the patterns you are trying to catch from the ones you want to ignore. A single week of data cannot tell you which is which — you need to compare a recent window against a baseline window, and look at the shape of the change.
| Pattern | Signal in GSC | Typical cause | Action |
|---|---|---|---|
| Gradual decay | Clicks down 20–50% over 3+ months, position slipping 1–4 spots | Freshness gap, competitors updated, intent drift | Refresh & re-optimize |
| Algorithmic hit | Sharp drop (>40%) within a 1–2 week window across many URLs at once | Core or spam update | Diagnose site-wide, don’t refresh blindly |
| Seasonality | Predictable dip that recovers the same time each year | Query demand cycle | Ignore, or plan for the next peak |
| Cannibalization | Clicks migrate from one URL to another on the same query | Two competing pages | Consolidate, pick a canonical |
| SERP feature loss | Impressions steady, CTR collapses at stable position | Lost featured snippet or AI Overview shift | Rework format for the feature |
The important insight is in the last two rows: not every decay is a ranking problem. A page can hold position 3 and still lose half its clicks because a SERP feature ate the real estate above it. That is why this pipeline looks at clicks, impressions, position, and CTR together instead of one metric in isolation.
Step 1: Pull the data from Search Console
You need two windows from the Search Console API: a recent window (say the last 28 days) and a baseline window from earlier (the same 28 days, but 90–120 days ago). Query by page so each URL becomes one row you can compare.
from googleapiclient.discovery import build
from google.oauth2.credentials import Credentials
def query_window(service, site_url, start, end):
request = {
"startDate": start,
"endDate": end,
"dimensions": ["page"],
"rowLimit": 25000,
}
resp = service.searchanalytics().query(siteUrl=site_url, body=request).execute()
rows = {}
for r in resp.get("rows", []):
page = r["keys"][0]
rows[page] = {
"clicks": r["clicks"],
"impressions": r["impressions"],
"position": r["position"],
"ctr": r["ctr"],
}
return rows
service = build("searchconsole", "v1", credentials=creds)
recent = query_window(service, SITE, "2026-07-12", "2026-08-08")
baseline = query_window(service, SITE, "2026-03-14", "2026-04-10")
Using the same 28-day length for both windows matters. Comparing a 7-day window against a 28-day one is a classic mistake that manufactures fake “drops” out of nothing but window length.
Step 2: Score decay, not just percentage change
A 60% drop on a post that earned 5 clicks is noise. A 25% drop on a post that earned 800 clicks is a real problem. So the pipeline ranks by lost clicks (an absolute number) while using percentage and position change as filters. This keeps you working on the pages where a fix actually moves the needle.
def classify(page, r, b):
lost_clicks = b["clicks"] - r["clicks"]
pct = lost_clicks / b["clicks"] if b["clicks"] else 0
pos_delta = r["position"] - b["position"] # positive = worse
ctr_delta = r["ctr"] - b["ctr"]
# Ignore low-signal pages
if b["clicks"] < 20:
return None
if pct >= 0.2 and pos_delta >= 0.5:
label = "gradual_decay"
elif pct >= 0.2 and abs(pos_delta) < 0.5 and ctr_delta < -0.01:
label = "serp_feature_loss" # position held, CTR fell
elif pct >= 0.2:
label = "review"
else:
return None
return {
"page": page, "label": label,
"lost_clicks": round(lost_clicks, 1),
"pct_drop": round(pct * 100, 1),
"pos_delta": round(pos_delta, 2),
}
decaying = []
for page, r in recent.items():
b = baseline.get(page)
if not b:
continue
result = classify(page, r, b)
if result:
decaying.append(result)
decaying.sort(key=lambda x: x["lost_clicks"], reverse=True)
The serp_feature_loss branch is the piece most decay scripts miss. When position is stable but CTR fell, refreshing the copy will not help — the answer is to change the format so you can win back the snippet, or to accept that an AI Overview now sits above you and adjust the goal.
Step 3: Separate decay from an algorithm hit
Before you queue a hundred refreshes, check whether the drops cluster in time. If a large share of your decaying URLs all dropped in the same one-to-two-week window, you are probably looking at an algorithmic event, not independent decay — and the fix is a site-wide diagnosis, not a page-by-page refresh. A simple heuristic: if more than ~30% of flagged URLs share a drop week, stop and investigate the update first. For a full treatment of correlating drops with confirmed update dates, see the companion pipeline on correlating ranking drops with Google algorithm updates.
Step 4: Prioritize and route the output
The final step turns the ranked list into something a human will actually act on. Write the top decaying pages to a sheet or a weekly digest, grouped by label so refresh candidates and format-fix candidates land in separate buckets. Before you rewrite a flagged page, it is worth running it through an LLM content-quality scorer aligned to Google’s rater guidelines so you fix the weakest pages first. Cap the list — the top 10 by lost clicks each week is a sane, finishable queue. A pipeline that dumps 200 “problems” gets ignored; one that hands over the ten highest-leverage fixes gets done.
import csv
with open("decay_queue.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["page","label","lost_clicks","pct_drop","pos_delta"])
w.writeheader()
for row in decaying[:10]:
w.writerow(row)
How often to run it
Monthly is the right cadence for content decay. Decay is a slow process, and running weekly just adds noise — you will react to normal week-to-week variance instead of a real trend. Schedule it on the first of the month, feed the queue into whatever refresh workflow you already run — it slots neatly into an automated Search Console reporting digest — and re-measure the affected URLs 4–6 weeks after you update them so you can prove the refresh worked.
Frequently asked questions
How is content decay different from a Google algorithm update?
Decay is gradual and page-specific: individual URLs lose clicks over months as freshness fades or competitors update. An algorithm update is sharp and correlated: many URLs drop together within a one-to-two-week window. The tell is timing — if your flagged pages all dropped in the same week, treat it as an update, not decay.
Why rank by lost clicks instead of percentage drop?
Percentage change is misleading on low-traffic pages, where a handful of clicks swings the number wildly. Ranking by absolute lost clicks focuses your limited refresh time on the pages where recovering traffic actually matters to the site’s totals.
Can I run this without coding?
Partly. You can pull the two windows from the Search Console UI, drop them into a spreadsheet, and compute the difference by hand for a small site. The Python approach pays off once you have enough URLs that manual comparison stops being practical — roughly the 80–100 URL mark.
Does refreshing a decaying page always bring the traffic back?
No. If the loss came from a SERP feature change or shifting search intent, rewriting the same content will not recover the clicks. That is why the pipeline separates gradual_decay (a refresh candidate) from serp_feature_loss (a format or strategy problem).
The takeaway
Content decay is unglamorous and easy to ignore precisely because it never triggers an alarm. A small monthly pipeline that compares two Search Console windows, ranks by lost clicks, and separates true decay from algorithmic noise turns that invisible leak into a short, finishable maintenance queue. The sites that compound are usually not the ones publishing the most — they are the ones that stopped letting their best old posts quietly die.
