Content Decay Triage: Decompose the Drop Before You Rewrite
Clicks are impressions times CTR. Decompose a traffic drop into both factors per query cohort and the fix picks itself, or tells you not to rewrite.
Clicks is not a metric you can debug. It is the product of two metrics you can:
clicks = impressions × CTR
That identity holds exactly, in every Search Console row, at every level of aggregation. A page lost clicks through fewer impressions, through a lower click rate on the impressions it still gets, or through some mix. Those are different failures with different causes, and the correct operation is different for each. Rewriting the page is the correct response to exactly one of them.
My claim is not that most of what gets called decay is CTR loss; I have no number for that, and neither does anyone publishing one. The narrower claim is that the modes are separable in data you already have, the separation takes about an hour, and almost nobody does it before opening the CMS.
The decomposition
Position is not a third factor in the identity. It explains the other two, and conflating it with them is where decay analysis goes wrong. Read Google’s definitions literally rather than from memory: an impression is counted “whenever an item appears in the current page of results, whether or not the item is scrolled into view, as long as the user need not click to see more results,” and the reported position “averages the position value for all impressions.”
So position affects CTR continuously but affects impressions as a step function. Slide from 3 to 7 and impressions are unchanged, because the result is still on the current page of results. Slide from 10 to 11 and they collapse, because the user now has to click to see more results before your link exists at all. The page-one boundary is the only place where a ranking loss shows up primarily as an impression loss; everywhere inside page one it shows up as CTR loss. And impressions move for reasons that have nothing to do with you: demand rises and falls, eligibility for a query set widens and narrows. Neither is a page-quality event.
The arithmetic that has no residual
Baseline impressions and clicks are i0, c0, with r0 = c0 / i0; the current period is i1,
c1, r1. The naive split, (i1 - i0) * r0 against i0 * (r1 - r0), leaves the interaction
term (i1 - i0) * (r1 - r0) unallocated, and when both factors move it is not small. Split it
evenly and the decomposition is exact:
Δclicks_from_impressions = (i1 - i0) * (r0 + r1) / 2
Δclicks_from_ctr = (r1 - r0) * (i0 + i1) / 2
The two terms sum to c1 - c0 identically. With round numbers: a cohort going from 100,000
impressions and 4,000 clicks to 96,000 and 2,880 lost 1,120 clicks; the impression term is
-4,000 × 0.035 = -140, the CTR term -0.01 × 98,000 = -980. Twelve and a half percent of the
loss is reach, the rest is click rate. That is arithmetic, not a measurement; run it to confirm
your implementation reproduces it. For cohorts of very different sizes, ln(c1/c0) = ln(i1/i0) + ln(r1/r0) is also exact and additive, and fails only on zero clicks.
Over a BigQuery bulk export, build the
per-query, per-period aggregate first. Check the
column reference against your own
dataset: the URL-level table uses sum_position, the site-level table sum_top_position, both
zero-based, and average position is SUM(sum_position) / SUM(impressions) + 1.
CREATE OR REPLACE VIEW searchconsole.qp AS
SELECT
url,
query,
IF(data_date > DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY), 'now', 'base') AS period,
SUM(impressions) AS impressions,
SUM(clicks) AS clicks,
SAFE_DIVIDE(SUM(sum_position), SUM(impressions)) + 1.0 AS position
FROM searchconsole.searchdata_url_impression
WHERE search_type = 'WEB'
AND query != ''
AND (data_date > DATE_SUB(CURRENT_DATE(), INTERVAL 28 DAY)
OR data_date BETWEEN DATE_SUB(CURRENT_DATE(), INTERVAL 84 DAY)
AND DATE_SUB(CURRENT_DATE(), INTERVAL 57 DAY))
GROUP BY 1, 2, 3;
The decomposition needs no join. Conditional aggregation absorbs queries that entered or vanished into the impression term, which is where they belong.
WITH agg AS (
SELECT url, cohort,
SUM(IF(period = 'base', impressions, 0)) AS i0,
SUM(IF(period = 'now', impressions, 0)) AS i1,
SUM(IF(period = 'base', clicks, 0)) AS c0,
SUM(IF(period = 'now', clicks, 0)) AS c1
FROM searchconsole.cohorted
GROUP BY 1, 2
)
SELECT url, cohort, c1 - c0 AS d_clicks,
(i1 - i0) * (SAFE_DIVIDE(c0, i0) + SAFE_DIVIDE(c1, i1)) / 2 AS from_impressions,
(SAFE_DIVIDE(c1, i1) - SAFE_DIVIDE(c0, i0)) * (i0 + i1) / 2 AS from_ctr
FROM agg
WHERE i0 >= 500 AND i1 >= 500
ORDER BY d_clicks;
Without BigQuery, over two CSV exports:
import pandas as pd
def decompose(base: pd.DataFrame, now: pd.DataFrame, by: str) -> pd.DataFrame:
b = base.groupby(by)[["impressions", "clicks"]].sum().add_suffix("_0")
n = now.groupby(by)[["impressions", "clicks"]].sum().add_suffix("_1")
d = b.join(n, how="outer").fillna(0)
r0 = d.clicks_0 / d.impressions_0.where(d.impressions_0 > 0)
r1 = d.clicks_1 / d.impressions_1.where(d.impressions_1 > 0)
d["d_clicks"] = d.clicks_1 - d.clicks_0
d["from_impressions"] = (d.impressions_1 - d.impressions_0) * (r0 + r1) / 2
d["from_ctr"] = (r1 - r0) * (d.impressions_0 + d.impressions_1) / 2
return d.sort_values("d_clicks")
Cohorting queries, because the page-level average lies
Run the decomposition on whole pages and a large share of the answers will be artifacts. Two documented mechanics guarantee it. Average position is the topmost position of your link averaged across every impression, so a page holding position 3 on one commercial query and position 50 on four hundred long-tail queries has an average dominated by the tail, and the tail’s weight changes monthly for reasons unrelated to the page. Page CTR is a ratio of sums, so it moves whenever the mix moves, with no per-query rate changing at all.
A two-query construction shows it. Query A sits at position 3, query B at position 30:
| A: impr | A: clicks | B: impr | B: clicks | Total impr | Total clicks | Page CTR | Page avg pos | |
|---|---|---|---|---|---|---|---|---|
| Baseline | 1,000 | 200 | 200 | 2 | 1,200 | 202 | 16.8% | 7.5 |
| Current | 1,000 | 210 | 3,000 | 45 | 4,000 | 255 | 6.4% | 23.25 |
CTR on A went from 20% to 21%, on B from 1% to 1.5%. Both improved, clicks rose 26%, and the page-level view reports average position falling from 7.5 to 23.25 with CTR down by almost two thirds. Any decay rule built on those two columns flags this page for a rewrite. All it did was pick up long-tail reach.
So cohort before you decompose:
- Baseline position band: 1–3, 4–10, 11–20, 21+. CTR is a strong function of position, so this grouping removes the most mix noise, and it is what makes the constant-position test below possible at all.
- Branded against unbranded. Brand queries have their own CTR regime and their own demand curve; mixing them in measures brand strength, not the page.
- SERP format. Queries whose result set is the same shape belong together, which is where the SERP fingerprint you recorded before writing pays off a second time: cohorting by format becomes a lookup rather than a research project.
- An impression floor. Drop queries under roughly 100 impressions in either period. Their CTR is quantization noise and they are numerous enough to dominate an unweighted average.
Two coverage caveats belong beside every cohort table. Anonymized queries (those not issued by
more than a few dozen users over a two-to-three month period) arrive with an empty query field
and are frequently a property’s single largest bucket; filtering them with query != '' is
correct and silently removes real clicks, so state what share of the page’s impressions your
cohorts cover. And the Search Analytics
API “exposes a maximum
of 50K rows of data per day per search type,” and “when you group by page and/or query, our
system may drop some data.” An API dataset is a top-N sample, not a census. The bulk export has
no such cap.
Structural decline versus seasonality
Search Console keeps 16 months. Stated precisely, that buys you one year-over-year comparison, and one year-over-year comparison cannot distinguish a seasonal trough from the second year of a structural slide. You need two prior cycles; the product gives you one.
The way around it only works if you start now. The bulk data export does not backfill: “the first export will happen up to 48 hours after your successful configuration” and includes data “for the day of the export.” Everything earlier is gone when it ages out.
Until then, compare the cohort against baselines measured over the same window rather than against its own past in isolation. Two are usually available: the category baseline, meaning every page of the same template or section except the one under investigation, and the site baseline. A cohort whose clicks fell 30% while its category fell 28% has not decayed; demand moved, and Google lists demand shift as a distinct cause of a traffic drop alongside algorithmic, technical, security and spam causes. A cohort that fell 30% against a flat category is page-specific, and that is the one worth an hour.
SELECT
cohort,
SAFE_DIVIDE(SUM(IF(period = 'now', clicks, 0)),
SUM(IF(period = 'base', clicks, 0))) AS cohort_ratio,
SAFE_DIVIDE(SUM(SUM(IF(period = 'now', clicks, 0))) OVER (),
SUM(SUM(IF(period = 'base', clicks, 0))) OVER ()) AS site_ratio
FROM searchconsole.cohorted
GROUP BY cohort
ORDER BY cohort_ratio / site_ratio;
Sort on the ratio of the two ratios. Below 1, the cohort underperformed the site over the same weeks; at 1 it moved with everything else and there is nothing page-specific to fix.
One detail removes a lot of false signal: aggregate to weeks or months first. Google’s guidance on comparing date ranges is that “aggregating by week or month neutralizes the ‘day-of-the-week’ effect.” Two 28-day windows do the same, provided each contains the same count of every weekday, which is why 28 and not 30.
CTR loss at constant position
The standard refresh advice cannot see this mode because it never checks position per query. The signature: impressions roughly flat, position flat within a narrow band on the queries carrying the clicks, CTR down. Test it per query with an explicit stability filter. A page-level average that “looks flat” proves nothing.
SELECT
b.url,
COUNT(*) AS stable_queries,
SUM(b.impressions) AS i0, SUM(n.impressions) AS i1,
SAFE_DIVIDE(SUM(b.clicks), SUM(b.impressions)) AS ctr0,
SAFE_DIVIDE(SUM(n.clicks), SUM(n.impressions)) AS ctr1
FROM searchconsole.qp b
JOIN searchconsole.qp n USING (url, query)
WHERE b.period = 'base' AND n.period = 'now'
AND ABS(n.position - b.position) <= 0.3
AND LEAST(b.impressions, n.impressions) >= 100
GROUP BY 1
HAVING i0 >= 1000
ORDER BY ctr1 - ctr0;
The top rows are pages where the click rate fell on queries whose ranking did not move. That is the observation. The interpretation, stated as such: something above your link on the results page changed, and Search Console will not tell you what.
What Search Console does and does not report about AI surfaces
Most published writing on this predates the current reports. Google’s AI features documentation states that sites appearing in AI features “are included in the overall search traffic in Search Console” and are “reported on in the Performance report, within the ‘Web’ search type.” There is no AI Overviews filter or search-appearance value there. The counting rules: “an AI Overview occupies a single position in search results, and all links in the AI Overview are assigned that same position”; “clicking a link to an external page in the AI Overview counts as a click”; standard impression rules apply; and in AI Mode a follow-up question means the user is “essentially performing a new query” whose impressions, position and clicks go to that new query.
Since June 2026 there is also a Generative AI performance report for Search. Read what it contains before building on it: impressions only, with no clicks, no position and no query dimension. It excludes Search Labs experiments, and “not all properties have access to the report, as we’re rolling out over time.”
So you cannot compute a CTR for AI Overviews from Search Console: the surface with clicks has no AIO breakdown, and the surface with the AIO breakdown has no clicks. What you can compute is total CTR at constant position, which is the union of every layout change on that page: AI Overviews, a new feature block, an extra ad slot, a competitor gaining a rich result. Separating them requires observing the results page.
One arithmetic consequence of the documented rules is easy to miss. Reported position is the topmost position of any link to your page. If your page is cited inside an AI Overview at the top while your organic link sits lower than it used to, the topmost position is the AI Overview’s, so average position improves while clicks fall. That is a reading of the definitions rather than a measurement, and it means “position is fine, so ranking is fine” is not a valid inference on any query where an AI Overview appears.
Record the surface separately, then. The joinable field Search Console will not give you is a dated capture per query per week: whether an AI Overview rendered, whether your domain was among its links, how many organic results sat above the fold, which feature blocks were present. It is the fingerprint you take before writing rerun as a monitor, and its citation side is the AI answer measurement harness. There is now a control to exclude a site from generative AI features, documented as not “used as a ranking or inclusion signal affecting other parts of Search.” It removes impressions. It does not return clicks.
Choosing the operation
The decomposition plus the two baselines produce a small set of distinguishable signatures, each with one correct first operation.
| Signature | Reading | Operation |
|---|---|---|
| Impressions down, CTR flat, category down too | Demand moved | Nothing. Record it and move on |
| Impressions down, position crossed the page-one boundary | Ranking loss, not layout | Refresh or rewrite; check links |
| Impressions flat, CTR down, position flat | SERP layout changed | Not a content fix. Title and snippet, or repurpose |
| Impressions flat, CTR down, position down within page one | Ranking loss inside the visible set | Rewrite against the current top set |
| Impressions up, clicks down, average position down | Cohort mix shift | Nothing. Fix the dashboard |
| Impressions split across two of your URLs on one query | Not decay | Consolidate |
| Position stable and high, CTR far below cohort median | Snippet problem | Rewrite title and description only |
The last row is the cheapest test here and the one most often skipped: within a position band, compute the cohort’s median CTR and flag queries whose page sits well below it at the same position. A title and description change ships in minutes, recrawls quickly, and is a clean single-variable experiment, which almost nothing else in SEO is.
The alternation case belongs to a different instrument. Two of your URLs trading impressions on one query is a split, resolved by the query–URL matrix and its decision rule; refreshing one of two cannibalizing pages usually just moves which one wins.
What a refresh actually consists of
The edits that reliably change nothing, in the order people reach for them: bumping the displayed date, regenerating the introduction, swapping synonyms, adding an FAQ block, adding headings that restate existing headings, increasing word count. None change what the page asserts or what it covers, and all produce a diff large enough to feel like work.
A refresh is an operation on the page’s claim set. Enumerate the assertions the page makes, and against the current ranking set for the cohort’s queries ask three questions:
- Which assertions are now false? Version numbers, quotas, deprecated interfaces, prices, product names, statements about how a system behaves that stopped being true. This is a correctness problem before it is an SEO problem.
- Which entities and sub-questions does the ranking set cover that you do not? Not keyword gaps: concrete things being explained that your page does not explain.
- Which sections answer a question nobody asks any more? Deletion is an edit. A page refreshed four times with nothing ever removed is getting worse.
On dates, Google
requires that “the
dates must describe the publication or update date of the page, not the stories or events
described therein,” and its News best
practices put it flatly: “it’s
against our guidelines to artificially freshen a story when the publisher didn’t add significant
information or demonstrated a compelling reason.” Substantive edit, new dateModified; typo
fix, unchanged date. Hold that line because a truthful dateModified is one of the few clean
event markers you will have at the measurement step.
Proving it worked, with a control cohort
A refresh does two things at once: it changes the page and it triggers a recrawl. The recrawl is an event in its own right, and the days after one are noisy in a way that has nothing to do with your edits. That noise gets reported as a refresh effect routinely, which is the reason to run refreshes as batches with controls rather than one page at a time.
Rank the decaying pages by the decomposition, then split them stratified so both arms carry the same decay profile. The natural failure is to refresh the important pages first, which makes the treatment arm different from the control arm before anything is edited.
import numpy as np
import pandas as pd
pages = pages.sample(frac=1.0, random_state=20260831)
pages["stratum"] = pd.qcut(pages.d_clicks, 5, labels=False, duplicates="drop")
pages["arm"] = np.where(pages.groupby("stratum").cumcount() % 2 == 0,
"treatment", "control")
Four rules for reading it.
Report the ratio, not the level. The metric is treatment-arm clicks indexed to control-arm clicks over the same weeks. An update, a season and a site-wide change hit both arms and cancel in the ratio; absolute clicks answer a question you did not ask.
Do not start the clock at publication; start it when the recrawl is observed. The access-log pipeline gives the fetch timestamp per URL, which is the honest zero point; pages in one batch can be recrawled days apart, and a treatment page that has not been recrawled is not yet treated.
Wait longer than is comfortable. Four full weeks after the last treatment page is recrawled is a reasonable floor. Reading a refresh at day five is reading crawl noise.
And discount the newest days. The most recent data is preliminary, a dotted line on the chart subject to change, so keep it off any window boundary.
Then publish the number. A batch that moves the treatment/control index by less than its own week-to-week variance is a null result, and a null on twenty pages is worth more than a positive on one, because it says the operation was wrong for that signature rather than that one page was unlucky.
Pages that should not be saved
Retirement is an outcome, not a failure of the triage. The candidates are recognizable: cohorts whose impressions fell structurally against a flat category baseline over two or more quarters, pages whose queries no longer exist because the underlying thing stopped existing, pages surviving on a long tail with no per-query CTR worth having, near-duplicates that were only ever a template artifact.
Three options and one non-option. Redirect to the nearest genuinely equivalent surviving page.
Return 410 where there is none; a deliberate gone is cheaper than a soft 404 you will be
diagnosing next quarter. Keep it and noindex it if it still serves a need that search does not
express. The non-option is the pattern Google’s News guidelines name explicitly: “create a very
slightly updated story from a previously published one, then delete the old story and redirect
to the new one.” That is a rewrite wearing a redirect.
Retiring pages is what makes the survivors measurable. Every URL that will not earn another click is impressions in your averages, rows in your exports, and a crawl request that could have gone elsewhere. The triage is worth running because it ends with a shorter list than it started with. A fair share of the time, the correct entry against a decaying page is that nothing decayed and nothing needs doing.