Keyword cannibalization is the most over-diagnosed condition in SEO. The standard test — export your Search Console query-page report, find queries where more than one URL appears, declare a problem — flags hundreds of queries on any site with more than a few hundred pages. Most of them are fine. Some of them are pages Google is deliberately showing together.
The useful question is not "do two URLs rank for this query" but "is Google's uncertainty about which URL to serve costing me clicks." Those are different questions, and only the second one is worth a content merge. This post covers what Google actually does when several of your pages match one query, what Search Console's data model can and cannot tell you about it, and a computable signal that separates harmless overlap from real dilution.
What Google actually does when two of your URLs match one query
Three separate mechanisms are at play, and conflating them is where most bad diagnoses start.
Canonicalization. If two URLs are near-duplicates, Google picks one and folds the signals together. This is not cannibalization; it is the system working. You will see it in Search Console as one URL earning impressions and the other appearing under "Duplicate, Google chose different canonical" in the Pages report.
Host crowding limits. Google announced a site diversity change in 2019 that generally limits the number of results from a single site in the top results, with exceptions for cases where the algorithm judges more results from one domain to be especially relevant. The practical consequence: when several of your pages are eligible, they are not all competing for adjacent slots. One or two get through, the rest are filtered out of the visible SERP.
Per-query URL selection. For a single query, Google ranks documents, not sites. If two of your pages score close together, small day-to-day scoring changes can swap which one surfaces. That swap is visible in your data and it is what people usually point at as evidence of cannibalization. It is evidence of a close call, not necessarily of harm.
The reasonable inference — not documented behavior — is that a swap costs you something only when the alternating URLs perform differently. If both convert impressions to clicks at similar rates, which one Google picks is close to irrelevant to you.
What Search Console's data model hides
Before computing anything, know the constraints baked into the source.
- Position is the topmost position, averaged. Search Console records the highest position your URL reached for that query on that impression, then averages. An average of 4.0 can be a stable rank 4 or a mix of 1s and 8s. You cannot distinguish them from the aggregate.
- Adding the query dimension shrinks the totals. Queries below a privacy threshold are dropped when you filter or group by query. Page-level totals will always exceed the sum of query-level rows for the same page. Never compute a cannibalization ratio by mixing the two grain levels.
- Impressions are logged against the URL that appeared. If two URLs never appear on the same day for the same query, you get two clean time series, not one contested row. Daily granularity matters — a monthly export smears the swap into a tie.
- The UI caps rows. The API returns more, and the BigQuery bulk export returns the full daily grain with
is_anonymized_queryflagged explicitly, which is the only source where you can see how much of a query set you are missing.
A cannibalization signal you can actually compute
The signal has two parts: instability (does the winning URL change) and underperformance (does CTR sit below what the position would predict). You need both. Instability alone is noise.
Over the Search Console bulk export, per query and week:
WITH rows AS (
SELECT
query,
url,
DATE_TRUNC(data_date, WEEK) AS wk,
SUM(impressions) AS impr,
SUM(clicks) AS clicks,
SAFE_DIVIDE(SUM(sum_top_position), SUM(impressions)) + 1 AS avg_pos
FROM `proj.searchconsole.searchdata_url_impression`
WHERE is_anonymized_query = FALSE
AND search_type = 'WEB'
GROUP BY query, url, wk
),
weekly_winner AS (
SELECT query, wk,
ARRAY_AGG(url ORDER BY impr DESC LIMIT 1)[OFFSET(0)] AS top_url,
SUM(impr) AS impr, SUM(clicks) AS clicks,
COUNT(DISTINCT url) AS url_count
FROM rows GROUP BY query, wk
)
SELECT query,
COUNT(DISTINCT top_url) AS distinct_winners,
COUNT(*) AS weeks,
SUM(impr) AS impr,
SAFE_DIVIDE(SUM(clicks), SUM(impr)) AS ctr,
MAX(url_count) AS max_urls_in_a_week
FROM weekly_winner
GROUP BY query
HAVING weeks >= 8 AND impr >= 500 AND distinct_winners >= 2
ORDER BY impr DESC
Then join the result to a CTR-by-position baseline built from your own data — median CTR for each integer position across the whole property. A query whose winning URL flipped three times and whose CTR sits well under your own baseline for its average position is a genuine candidate. A query that flipped but hits baseline CTR is not.
Building the baseline from your own property rather than a published curve matters. Branded queries, SERPs with AI Overviews, and verticals with heavy SERP features all have different CTR profiles, and an external curve will make half your site look broken.
Which pattern are you actually looking at
Once you have candidates, the fix depends on the cause. These are distinguishable from the data plus a look at the pages.
| Pattern | Data signal | Fix |
|---|---|---|
| Near-duplicate templates | Both URLs rank, near-identical titles, one often flagged as duplicate canonical | Consolidate to one URL, 301 the other |
| Intent split across pages | Different URLs win for different query variants; CTR at baseline | Leave it alone; strengthen the distinctions |
| Unresolved close call | Winner flips weekly, CTR below baseline, no clear intent split | Merge, or make one page clearly the target and internally link to it |
| Filter or parameter leakage | Winning URL includes query strings or facet paths | Canonicalize or block the parameter set |
| Pagination or archive pages surfacing | Page 2+ or tag pages winning brand-adjacent queries | Fix internal linking; make the hub the strongest candidate |
Note the second row. Two pages ranking for overlapping queries with healthy CTR is a sign your site covers a topic at more than one level of specificity. Merging those pages reduces coverage and can lose you the long tail.
What to do with this
- Run the query on daily-grain data, weekly aggregated, with at least eight weeks of history. Monthly exports cannot show instability.
- Build your own CTR-by-position baseline from the same export, segmented at minimum into branded and non-branded.
- Rank candidates by impressions, not by count of overlapping URLs. A query with 50,000 impressions and one flip is worth more attention than a query with 200 impressions and six URLs.
- Check the Pages report before merging. If Google already consolidated the pair via canonicalization, there is nothing to fix and a 301 changes nothing.
- Treat merges as testable changes. Record the pre-merge CTR and impression baseline for the affected queries, and check whether the combined URL exceeds the better of the two originals. It often does not, and that result should change how aggressively you merge next time.
The general rule: cannibalization is a CTR problem masquerading as a URL-count problem. Measure the CTR.