WriteMySEO / Blog / Decomposing a Traffic Drop Into Demand, Mix, and CTR
SEO data

Decomposing a Traffic Drop Into Demand, Mix, and CTR

A shift-share method for splitting a Search Console click decline into query demand, segment mix, ranking position, and CTR — with the math and the SQL to run it.

Clicks are down 22% month over month. Someone says "algorithm update." Someone else says "content decay." Someone pulls up average position, sees it improved slightly, and everyone gets confused.

The confusion is structural. Clicks are a product of several independent quantities, and a single top-line number collapses all of them. You can pull those quantities back apart with nothing but Search Console data and a decomposition that has been standard in retail and finance analytics for decades: volume, mix, and rate.

Why average position can improve while clicks collapse

Your site's clicks in a period are the sum, across every segment (query × page × device × country), of impressions multiplied by that segment's click-through rate:

clicks = Σ impressionsᵢ × ctrᵢ

Site-level average position is an impression-weighted average of per-impression top positions. If you lose impressions on a high-volume, deep-ranking head query — the kind that generates thousands of impressions at position 14 and almost no clicks — your reported average position improves because the weighting changed, not because anything ranked better. Meanwhile the clicks that vanished came from somewhere else entirely.

This is ordinary Simpson's paradox. Every aggregate metric in Search Console is subject to it, because every aggregate is a weighted average over a segment mix that is itself moving.

The three-term decomposition, exactly

Write total clicks as C = I × c̄, where I is total impressions and c̄ is the impression-weighted average CTR: c̄ = Σ sᵢ cᵢ, with sᵢ the share of impressions in segment i.

Between period A and period B, the change in clicks splits with no residual:

The three sum exactly to C_B − C_A. Two caveats you should state out loud when you present this:

  1. The split is not unique. Attributing the interaction term to volume-first rather than rate-first changes the numbers. Pick one convention and never switch it mid-analysis.
  2. Segments that exist in only one period need a rule. The cleanest option is a fourth bucket: entry/exit, holding entirely new and entirely lost segments aside so they don't distort the mix term.

Splitting the rate effect into position and everything else

A CTR change within a segment has two very different causes, and you need to separate them before you draw a conclusion.

Build a CTR-by-position curve from your own data — global averages from third-party studies are the wrong baseline for your brand and your SERP types. Then for each segment compute an expected CTR at its period-B position using the period-A curve. Now:

The position component is a ranking story. The presentation component is a SERP story: a new AI Overview or People Also Ask block pushing organic results down the page, a rewritten title, a lost rich result, a new ad slot, a competitor with sitelinks. Google has documented that it rewrites titles when it judges the <title> a poor match; it has not published per-query data on how often it does so, so treat the presentation component as a signal to investigate, not a measurement of any single cause.

Impressions are not demand — decompose them too

The volume term is the one people most often misread as "search demand fell." It usually isn't, or isn't only.

Search Console counts an impression when your result appears on the results page the user viewed. If you drop from position 9 to position 13, you do not lose a few percent of impressions — you lose nearly all of them, because almost nobody loads page two. Impression loss therefore confounds fewer searches happening with you falling out of the shown result set.

The way out is a stable-set estimate:

  1. Take queries where your average position stayed within roughly ±1 across both periods and where you ranked on page one in both.
  2. Sum impressions for that set across both periods. The ratio approximates the demand change for your topic space, free of visibility effects.
  3. Apply that ratio to period-A impressions for the whole site. The difference between the implied and actual totals is visibility loss, not demand loss.

This is an estimate, not a measurement. It assumes your stable set is representative of the whole query portfolio, which is weakest when a single topic cluster drove the change.

What the data source will and won't let you do

LimitationEffect on the decomposition
Rare queries are anonymized and omitted from query rowsQuery-level segments undercount; site totals won't match the sum of segments
Position is averaged per impressionSegment-level positions hide bimodal distributions (e.g., ranking 3 in the US, 30 elsewhere)
Days are reported in Pacific TimeComparison windows must be built on Search Console's own dates, not your analytics property's time zone
16-month retention in the UIYear-over-year comparisons work; multi-year baselines don't, unless you've been running the BigQuery export

Because of anonymization, run the decomposition on the query-level table and reconcile the unexplained remainder against site totals separately. Don't quietly fold it into the mix term.

Running it on the BigQuery export

The bulk export gives you daily rows at query × page × country × device granularity. Note that positions in the export are zero-based, so add 1.

WITH seg AS (
  SELECT
    query, country, device,
    IF(data_date BETWEEN '2025-02-01' AND '2025-02-28', 'A', 'B') AS period,
    SUM(impressions)     AS impr,
    SUM(clicks)          AS clicks,
    SAFE_DIVIDE(SUM(sum_top_position), SUM(impressions)) + 1 AS avg_pos
  FROM `proj.searchconsole.searchdata_site_impression`
  WHERE search_type = 'WEB'
    AND is_anonymized_query = FALSE
    AND (data_date BETWEEN '2025-02-01' AND '2025-02-28'
      OR data_date BETWEEN '2025-03-01' AND '2025-03-28')
  GROUP BY 1,2,3,4
)
SELECT
  query, country, device,
  MAX(IF(period='A', impr, 0))   AS impr_a,
  MAX(IF(period='B', impr, 0))   AS impr_b,
  SAFE_DIVIDE(MAX(IF(period='A', clicks, 0)), MAX(IF(period='A', impr, 0))) AS ctr_a,
  SAFE_DIVIDE(MAX(IF(period='B', clicks, 0)), MAX(IF(period='B', impr, 0))) AS ctr_b
FROM seg GROUP BY 1,2,3

Compute shares and the three terms on top of that. Use equal-length windows ending on the same weekday, or the same calendar window year over year — a 28-day versus 31-day comparison injects a volume effect that is pure calendar artifact.

What to do with the output

Search ConsolemeasurementanalyticsBigQuery

WriteMySEO produces marketing content, not legal, medical, financial, or compliance advice. Figures cited reflect publicly reported industry data at time of writing and shift over time.

Get started

We write this well about your industry, every month.

AI-drafted, human-reviewed SEO content on a flat subscription. Blog posts, metadata, schema, and internal links, shipped on a monthly rhythm.

See plans

More from the blog