SEO & Digital Marketing Module 2 – Technical SEO: crawling and indexing
Canonical URLs and duplicate content
How Google picks one address from a group of duplicate pages, the signals that decide it, and how to fix http, www, slash and parameter copies.
What you will learn
- List the signals Google uses to choose a canonical URL, and set them so that they agree
- Fix common duplicates, from http and www to slashes, capitals, parameters and print views, with a redirect or a canonical
- Write a canonical link or Link header that Google can use, and spot one it ignores
- Diagnose "Duplicate, Google chose different canonical than user" with URL Inspection
Before you start
What you’ll learn · Before you start
What you will learn
- List the signals Google uses to choose a canonical URL, and set them so that they agree
- Fix common duplicates, from http and www to slashes, capitals, parameters and print views, with a redirect or a canonical
- Write a canonical link or Link header that Google can use, and spot one it ignores
- Diagnose "Duplicate, Google chose different canonical than user" with URL Inspection
Before you start
On this page
Pune Bakes’ mango mousse page answers at twelve addresses: with and without www, over http and https, with a
slash at the end, with capital letters, with tracking parameters from Instagram and Facebook, as a print view and at
the old shop’s product.php?id=482. To Google most of them are copies of one page, and it shows only one of them.
Choosing that one is called canonicalisation: How Google picks one representative address from a group of pages with the same main content. A site can state its choice with redirects, canonical tags and its sitemap, but Google treats the choice as a hint., and the site has a say in it. By the end of this page you can list the signals Google uses to choose a canonical URL: The address Google picks as the most representative of a group of duplicate pages; it is the one that may be shown in results. A site's preferred canonical is a hint, not a rule. More in “How Google Search works: crawl, index, serve” and make them agree, fix the common causes of copies with a redirect or a canonical tag, write a canonical that Google can use, and read what Search Console says when Google chooses another address.
Duplicates are normal, and Google keeps one address of each group
When Google indexes pages, it works out the main content of each one, the part that is not header, menu or footer, and puts pages whose main content is the same or nearly the same into one group. From each group it picks the page it judges “objectively the most complete and useful for search users” as the canonical (What is URL canonicalization). The canonical is the one results usually link to, it is crawled most often, and the others, the duplicates, are crawled less.
Google names five ordinary reasons for duplicate content: The same or nearly the same main content at more than one address. Within a site it is normal and not a spam-policy violation: Google groups the copies and shows one address of each group., and the bakery has an example of most of them:
| Reason Google names | What it looks like | The bakery’s case |
|---|---|---|
| Protocol variants | The same page on http and https |
http://punebakes.example/cakes/mango-mousse |
| Site functions | A list sorted or filtered | /cakes/?sort=price |
| Device variants | A mobile and a desktop version | None: one page serves every screen |
| Region variants | Pages for two countries in one language | None yet |
| Accidental variants | A demo or test copy left open to crawlers | A staging copy, if it were not behind a login |
None of this is a fault in itself. Google says that “some duplicate content on a site is normal”, and its starter guide adds that it is not a violation of its spam policies and not something that will cause a manual action (SEO Starter Guide). What copies do cost is control: if the site says nothing, Google chooses the address people see, and visits, links and reports are split between the copies. Step through what happens to the twelve addresses:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Twelve addresses. Googlebot meets the mango mousse page at twelve addresses, from links, campaigns and old menus. Three answer with a 301 to the bakery's preferred address; the other nine answer 200.
- One group of copies. Google compares the main content of the pages, not the headers and footers. Eight of the nine show the same English text, so they form one group of duplicates; the redirects join the group of their target.
- Another language stands alone. The ninth page shows the same cake in Marathi. A page in another language is a duplicate only if its main content is in the same language, so it forms a group of its own and keeps its own canonical.
- The signals vote. For the English group Google weighs what the site says. The three 301s and the canonical tags name /cakes/mango-mousse, and so does the sitemap. HTTPS also counts, because Google prefers an HTTPS page over the same page on HTTP.
- A hint, not a rule. Google chooses the address it judges the most complete and useful. The site's signals are hints that weigh heavily when they agree; when they point different ways, Google may choose another address.
- The canonical is what shows. Results link to the canonical, and Google crawls it most often and the copies less. What Google knows about the copies, such as the links that point at them, is gathered on the canonical.
The Marathi page did not join the group. Language versions are duplicates only when their main content is in the same language, so a translated page keeps its own canonical, and only translating the menu and the footer does not make a page distinct.
Checkpoint
Check: Duplicates and the canonical Google keeps
Redirects and canonical tags are strong signals; a sitemap is weak
A site states its choice with signals, and Google ranks them by strength (specify a canonical):
| Method | Strength | Use it when |
|---|---|---|
| A permanent redirect (301 or 308) | Strong: the target is treated as canonical | Nobody needs the copy any more |
| A rel=canonical: A link element in a page's head, or a Link header for files such as PDFs, that names the address a site prefers for a group of duplicate pages. Google counts it as a strong signal, not a rule. link in the head | Strong | The copy must keep working: tracking links, print views, sorted lists |
A canonical Link header |
Strong, for web search results | The copy is a PDF or another file without a head |
| Listing the main page in the sitemap | Weak | Always, alongside a strong signal |
The methods add up, so a site that uses two of them makes its choice more likely to be the one shown. Other signals count too. Google prefers an HTTPS page over the same page on HTTP, unless the HTTPS page has an invalid certificate, loads insecure content other than images, redirects to HTTP or names an HTTP canonical. It also reads the site’s own links, so internal links should point at the preferred address and never at a copy. Every page you prefer should name itself, which is called a self-referencing canonical: A canonical tag on the preferred page that names the page itself, so that copies created by tracking or sorting parameters still name the right address.: a copy made by a tracking parameter then still names the right page.
Three tools look like ways to choose a canonical and are not. A robots.txt block keeps Google from reading the copy at
all, while the copy’s address can still be indexed without its content. A noindex on a copy “will completely block the
page from Search”, which is why Google calls rel="canonical" the preferred solution. And the URL removal tool hides
every version of an address, the canonical included. Answer two questions about a copy of your own, and the table
marks the methods that fit:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Method | Names the main page | The copy keeps working | Works for PDFs and files |
|---|---|---|---|
| A 301 redirect Where it goes: the server, before any page is sent | Good a strong signal: Google treats the target as canonical | Poor every visitor and crawler is sent on to the main page | Good the server can redirect any address it answers |
| A rel=canonical link Where it goes: the head of the copy | Good a strong signal, when the other signals agree with it | Good the copy answers 200 as before | Poor only an HTML head can hold the tag |
| A canonical Link header Where it goes: the HTTP response of any file | Good a strong signal, used for web search results | Good the copy answers 200 as before | Good the header travels with PDFs and other files |
| Listing the main page in the sitemap Where it goes: the sitemap | Mixed a weak signal: Google still has to find the copies itself | Good nothing changes for visitors | Mixed it lists the main page and names no copy |
| noindex on the copy Where it goes: a meta tag or X-Robots-Tag | Poor removes the copy from Search and names no main page | Good visitors can still open the copy | Mixed X-Robots-Tag reaches files, but it is the wrong tool here |
| A robots.txt block of the copy Where it goes: robots.txt | Poor Google cannot read a blocked copy, which may still be indexed | Good visitors are not affected | Mixed it blocks files too, and names no main page |
How the answers decide
- “Must visitors and campaign links still open the copy as it is, such as a tracking link or a print view?” Yes, it must keep working: rules out A 301 redirect, noindex on the copy and A robots.txt block of the copy and adds a caveat to Listing the main page in the sitemap. A redirect would stop the copy working, and Google advises against noindex and robots.txt for choosing a canonical. A canonical keeps the copy working; a sitemap listing only adds a weak signal.
- “Must visitors and campaign links still open the copy as it is, such as a tracking link or a print view?” No, nobody needs it: rules out noindex on the copy and A robots.txt block of the copy and adds a caveat to A rel=canonical link, A canonical Link header and Listing the main page in the sitemap. Nobody needs the copy, so retire it with a 301, the strongest signal. A canonical alone would leave the copy answering, and a sitemap listing alone is weak.
- “Is the copy an HTML page, or a file such as a PDF?” A PDF or another file: rules out A rel=canonical link. A PDF has no HTML head for a link element, so its canonical travels in a Link header of the response.
- “Is the copy an HTML page, or a file such as a PDF?” An HTML page: adds a caveat to A canonical Link header. An HTML page can take the link element or the header. Use one of them, not both, because two declarations are more error-prone.
In short: a 301 for a copy nobody needs, a canonical link for an HTML copy that must keep working, a Link header for
a file, and the main page in the sitemap in every case.
Key idea
Use the strongest signal the copy allows, and make every signal, the redirects, the tags, the sitemap and the site’s own links, name the same address.
Checkpoint
Check: Signals and their strength
Every copy has a cause, and the cause decides the fix
Each of the bakery’s twelve addresses exists for a reason, and the reason says whether a visitor still needs it. The file below holds what the bakery’s server sends for each one: the status, the target of a redirect, and the start of the page:
[
{
"address": "https://punebakes.example/cakes/mango-mousse",
"status": 200,
"html": "<head><title>Mango mousse cake | Pune Bakes</title><link rel=\"canonical\" href=\"https://punebakes.example/cakes/mango-mousse\"></head><main><h1>Mango mousse cake</h1><p>Eggless, made with Alphonso mango pulp, half a kilo or one kilo.</p></main>"
},
{
"address": "http://punebakes.example/cakes/mango-mousse",
"status": 301,
"location": "https://punebakes.example/cakes/mango-mousse"
},
{
"address": "https://www.punebakes.example/cakes/mango-mousse",
"status": 301,
"location": "https://punebakes.example/cakes/mango-mousse"
},
{
"address": "http://www.punebakes.example/cakes/mango-mousse",
"status": 301,
"location": "https://punebakes.example/cakes/mango-mousse"
},
{
"address": "https://punebakes.example/cakes/mango-mousse/",
"status": 200,
"html": "<head><title>Mango mousse cake | Pune Bakes</title><link rel=\"canonical\" href=\"https://punebakes.example/cakes/mango-mousse\"><link rel=\"canonical\" href=\"https://punebakes.example/cakes/mango-mousse/\"></head><main><h1>Mango mousse cake</h1><p>Eggless, made with Alphonso mango pulp, half a kilo or one kilo.</p></main>"
},
{
"address": "https://punebakes.example/cakes/Mango-Mousse",
"status": 200,
"html": "<head><title>Mango mousse cake | Pune Bakes</title></head><main><h1>Mango mousse cake</h1><p>Eggless, made with Alphonso mango pulp, half a kilo or one kilo.</p></main>"
},
{
"address": "https://punebakes.example/cakes/mango-mousse?utm_source=instagram&utm_medium=social",
"status": 200,
"html": "<head><title>Mango mousse cake | Pune Bakes</title><link rel=\"canonical\" href=\"https://punebakes.example/cakes/mango-mousse\"></head><main><h1>Mango mousse cake</h1><p>Eggless, made with Alphonso mango pulp, half a kilo or one kilo.</p></main>"
},
{
"address": "https://punebakes.example/cakes/mango-mousse?fbclid=IwAR2x",
"status": 200,
"html": "<head><title>Mango mousse cake | Pune Bakes</title><link rel=\"canonical\" href=\"https://punebakes.example/cakes/mango-mousse\"></head><main><h1>Mango mousse cake</h1><p>Eggless, made with Alphonso mango pulp, half a kilo or one kilo.</p></main>"
},
{
"address": "https://punebakes.example/cakes/mango-mousse?ref=menu",
"status": 200,
"html": "<head><title>Mango mousse cake | Pune Bakes</title><link rel=\"canonical\" href=\"https://punebakes.example/cakes/mango-mousse?ref=menu\"></head><main><h1>Mango mousse cake</h1><p>Eggless, made with Alphonso mango pulp, half a kilo or one kilo.</p></main>"
},
{
"address": "https://punebakes.example/print/cakes/mango-mousse",
"status": 200,
"html": "<head><title>Mango mousse cake (print) | Pune Bakes</title><link rel=\"canonical\" href=\"https://punebakes.example/cakes/mango-mousse\"></head><main><h1>Mango mousse cake</h1><p>Eggless, made with Alphonso mango pulp, half a kilo or one kilo.</p></main>"
},
{
"address": "https://punebakes.example/product.php?id=482",
"status": 200,
"html": "<head><title>Mango mousse cake | Pune Bakes</title></head><main><h1>Mango mousse cake</h1><p>Eggless, made with Alphonso mango pulp, half a kilo or one kilo.</p></main>"
},
{
"address": "https://punebakes.example/cakes/mango-mousse?lang=mr",
"status": 200,
"html": "<head><title>मँगो मूस केक | Pune Bakes</title><link rel=\"canonical\" href=\"https://punebakes.example/cakes/mango-mousse?lang=mr\"></head><main><h1>मँगो मूस केक</h1><p>अंड्याशिवाय, हापूस आंब्याच्या गरापासून, अर्धा किलो किंवा एक किलो.</p></main>"
}
] The program reads it, groups the pages by their main text, works out the preferred address with the bakery’s rules and says what each address needs:
"""Twelve addresses that answer with Pune Bakes' mango mousse page, and what each one needs.
responses.json holds what the bakery's server sends for each address: a status, a Location for a redirect, and the
HTML of a page. The program groups the pages by their main text, as Google groups duplicates, works out the one
address the bakery prefers for each, and says whether an address needs a redirect, a canonical tag, or nothing.
"""
import json
from html.parser import HTMLParser
from urllib.parse import parse_qsl, urlencode, urlsplit
SITE = "https://punebakes.example"
TRACKING = ("utm_", "fbclid", "gclid", "msclkid", "ref") # parameters that never change what the page shows
OLD_IDS = {"482": "/cakes/mango-mousse"} # the shop's old product.php addresses
class Page(HTMLParser):
"""Reads the canonical link of a page and the text of its main element."""
def __init__(self, html):
super().__init__()
self.canonical, self.main, self.inside = None, "", False
self.feed(html)
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if tag == "link" and a.get("rel") == "canonical":
self.canonical = a.get("href")
self.inside = self.inside or tag == "main"
def handle_data(self, data):
self.main += data if self.inside else ""
def preferred(address):
"""https, no www, lower case, no slash after a product, no tracking parameters, no print folder."""
u = urlsplit(address)
path = u.path.lower().removeprefix("/print")
query = [(k, v) for k, v in parse_qsl(u.query) if not k.startswith(TRACKING)]
if path == "/product.php":
path, query = OLD_IDS[dict(query)["id"]], []
if path.count("/") > 2:
path = path.rstrip("/")
return SITE + path + ("?" + urlencode(sorted(query)) if query else "")
def short(address):
return address.removeprefix(SITE) or "/"
def needs(r, want):
if r["status"] == 301:
return "nothing: one 301 to it" if r["location"] == want else f"301 to {short(want)}, in one hop"
page = Page(r["html"])
if r["address"] == want:
return "nothing: it names itself" if page.canonical == want else "a canonical tag to itself"
u = urlsplit(r["address"])
reachable = u.path.startswith("/print/") or any(k.startswith(TRACKING) for k, _ in parse_qsl(u.query))
if not reachable:
return f"301 to {short(want)}"
return "nothing: its canonical names it" if page.canonical == want else f"canonical tag to {short(want)}"
responses = json.load(open("responses.json", encoding="utf-8"))
by_address = {r["address"]: r for r in responses}
groups = {} # main text -> addresses that show it (a redirect shows its target's page)
for r in responses:
shown = by_address[r["location"]] if r["status"] == 301 else r
groups.setdefault(Page(shown["html"]).main, []).append(r)
sizes = ", ".join(f"one at {len(g)}" for g in groups.values())
print(f"{len(responses)} addresses show {len(groups)} different pages: {sizes}")
fixes = []
for group in groups.values():
want = preferred(group[0]["address"])
print(f"\nThe page whose preferred address is {short(want)}:")
for r in group:
need = needs(r, want)
fixes += [need] if not need.startswith("nothing") else []
shown = short(r["address"]) if r["address"].startswith(SITE + "/") else r["address"]
print(f" {r['status']} {shown}\n needs {need}")
redirects = sum(f.startswith("301") for f in fixes)
print(f"\nTo fix: {redirects} addresses need a 301, {len(fixes) - redirects} needs a canonical tag") Output
12 addresses show 2 different pages: one at 11, one at 1
The page whose preferred address is /cakes/mango-mousse:
200 /cakes/mango-mousse
needs nothing: it names itself
301 http://punebakes.example/cakes/mango-mousse
needs nothing: one 301 to it
301 https://www.punebakes.example/cakes/mango-mousse
needs nothing: one 301 to it
301 http://www.punebakes.example/cakes/mango-mousse
needs nothing: one 301 to it
200 /cakes/mango-mousse/
needs 301 to /cakes/mango-mousse
200 /cakes/Mango-Mousse
needs 301 to /cakes/mango-mousse
200 /cakes/mango-mousse?utm_source=instagram&utm_medium=social
needs nothing: its canonical names it
200 /cakes/mango-mousse?fbclid=IwAR2x
needs nothing: its canonical names it
200 /cakes/mango-mousse?ref=menu
needs canonical tag to /cakes/mango-mousse
200 /print/cakes/mango-mousse
needs nothing: its canonical names it
200 /product.php?id=482
needs 301 to /cakes/mango-mousse
The page whose preferred address is /cakes/mango-mousse?lang=mr:
200 /cakes/mango-mousse?lang=mr
needs nothing: it names itself
To fix: 3 addresses need a 301, 1 needs a canonical tag
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 canonical_for.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The program found 2 pages, not 12, and 4 things to fix. Read the causes from its output:
- Protocol and host. The three
httpandwwwaddresses already answer with one 301 straight to the preferred address, as the first lesson of this module set them up. Nothing to do. - Trailing slash and capital letters. In the URL standard only the scheme and the host are case-insensitive, and
nothing makes a path with a slash at the end equal to one without
(RFC 3986, section 6); Google treats
/APPLEand/appleas different addresses (URL structure). The bakery’s server answers/cakes/mango-mousse/and/cakes/Mango-Moussewith the same page, so each becomes a 301 to the one spelling the site uses. - Tracking parameters. The
utm_sourceandfbclidcopies must keep working, or the campaigns lose what their parameters record, so they keep answering 200 and their canonical names the page.?ref=menunames itself instead: that is the one canonical tag to fix. A tracking parameter: A query parameter such as utm_source, gclid or fbclid that records where a visit came from and does not change what the page shows. It belongs in links from outside, never in a site's own links or sitemap. belongs in links from outside, never in the site’s own links or its sitemap. - The print view must keep working too, so it keeps its canonical to the page: for copies within a site Google advises a canonical, not noindex.
- The old shop’s address,
product.php?id=482, still answers 200: a copy nobody needs, so a 301. - The Marathi page is a page of its own and names itself; the lesson on international SEO joins such pages with hreflang.
A fragment is not on the list at all. /cakes/mango-mousse#reviews never reaches the server, and Google generally
does not support fragments, so it is the same address to Google and never a canonical.
Change one rule
Press Edit, delete "ref" from TRACKING and run again: ?ref=menu no longer counts as a tracking link that must
keep working, so the program asks for a fourth 301 and no canonical tag, and that redirect would drop the parameter
before the page could record which menu link was used. Whether a parameter belongs in that list depends on one
question, whether it changes what the page shows; ref only records which menu link was used.
Checkpoint
Check: Causes of copies and their fixes
A canonical tag counts only if Google can use it
The link element looks simple, <link rel="canonical" href="https://punebakes.example/cakes/mango-mousse">, and most
of its failures are invisible in a browser. Google accepts it only in the <head>, and it reads the head only as far
as the first element that does not belong there: the valid ones are title, meta, link, script, style,
base, noscript and template, and at anything else, such as an img or an iframe, Google assumes the head has
ended and ignores what follows (valid page metadata).
Google also asks for absolute addresses, accepts the relative ones but warns that they can cause problems later, for
example when a test copy of the site gets crawled, and asks for one method per page, the element or the header, because
using both is more error-prone. A canonical link that carries an hreflang, lang, media or type attribute is not
used for canonicalisation at all.
The header form is the one for files: Link: <https://punebakes.example/menu/>; rel="canonical", with the address in
angle brackets (specify a canonical).
The link relation itself is defined in RFC 6596. The program below
checks eight of the bakery’s declarations, using Python’s HTML parser
(html.parser) and a list of how each target answers:
"""Check the canonical declarations of eight Pune Bakes addresses against Google's rules.
Each page lists the HTML its server sends (only the start of it) and any Link header. ANSWERS says how each target
answers. A canonical counts only in a valid head: Google stops reading the head at the first element that does not
belong there, such as an img. The target should be an absolute address that answers 200 and may be indexed.
"""
from html.parser import HTMLParser
SITE = "https://punebakes.example"
HEAD_ELEMENTS = {"title", "meta", "link", "script", "style", "base", "noscript", "template"}
ANSWERS = { # address -> (status, carries noindex)
"/cakes/rasmalai-cake": (200, False),
"/cakes/eggless-truffle": (200, False),
"/cakes/blueberry-cake": (301, False),
"/offers/": (200, True),
"/cakes/coffee-walnut-cake": (404, False),
"/cakes/mango-mousse": (200, False),
"/menu/": (200, False),
}
C = '<link rel="canonical" href="https://punebakes.example' # the start of an absolute canonical link
PAGES = [ # address, the start of the HTML its server sends, its Link header (or None)
("/cakes/rasmalai-cake", f'<head><title>Rasmalai cake</title>{C}/cakes/rasmalai-cake"></head>', None),
("/cakes/eggless-truffle", '<head><link rel="canonical" href="/cakes/eggless-truffle"></head>', None),
("/cakes/red-velvet-heart",
f'<head><title>Red velvet</title><img src="/badge.png">{C}/cakes/red-velvet-heart"></head>', None),
("/cakes/mango-mousse/", f'<head>{C}/cakes/mango-mousse">{C}/cakes/mango-mousse/"></head>', None),
("/cakes/blueberry-cheesecake", f'<head>{C}/cakes/blueberry-cake"></head>', None),
("/offers/diwali-hampers", f'<head>{C}/offers/"></head>', None),
("/cakes/coffee-walnut", f'<head>{C}/cakes/coffee-walnut-cake"></head>', None),
("/menu.pdf", "", '<https://punebakes.example/menu/>; rel="canonical"'),
]
class Head(HTMLParser):
"""The canonical links Google reads: those before the head ends, or before an element that ends it early."""
def __init__(self, html):
super().__init__()
self.read, self.ignored, self.open, self.ended_by = [], [], True, None
self.feed(html)
def handle_starttag(self, tag, attrs):
if self.open and tag not in HEAD_ELEMENTS | {"head"}:
self.open, self.ended_by = False, tag # Google assumes the head ended here
a = dict(attrs)
if tag == "link" and a.get("rel") == "canonical":
(self.read if self.open else self.ignored).append(a.get("href", ""))
def handle_endtag(self, tag):
self.open = self.open and tag != "head"
def problems(address, html, header):
head = Head(html)
named = head.read + ([header.split(">")[0].lstrip("<")] if header else [])
found = [f"canonical after <{head.ended_by}> is ignored: the head ended there" for _ in head.ignored]
if len(set(named)) > 1:
found.append(f"{len(named)} canonicals that disagree")
for href in dict.fromkeys(named): # each address once, in the order the page names them
target = href.removeprefix(SITE)
if not href.startswith("https://"):
found.append(f"{href} is relative: write the absolute address")
status, noindex = ANSWERS.get(target, (None, False))
if status not in (200, None):
found.append(f"the target {target} answers {status}")
if noindex:
found.append(f"the target {target} carries noindex")
if not named and not head.ignored:
return ["no canonical"]
return found or ["ok"]
for address, html, header in PAGES:
print(address)
for problem in problems(address, html, header):
print(f" {problem}") Output
/cakes/rasmalai-cake ok /cakes/eggless-truffle /cakes/eggless-truffle is relative: write the absolute address /cakes/red-velvet-heart canonical after <img> is ignored: the head ended there /cakes/mango-mousse/ 2 canonicals that disagree /cakes/blueberry-cheesecake the target /cakes/blueberry-cake answers 301 /offers/diwali-hampers the target /offers/ carries noindex /cakes/coffee-walnut the target /cakes/coffee-walnut-cake answers 404 /menu.pdf ok
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_tags.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Two pass: the rasmalai cake’s tag and the menu PDF’s header. The other six each have a different problem. The red velvet page’s tag
sits after an img that a badge plugin put in the head, so Google never reads it. The mango mousse copy has two tags
that disagree, one from the theme and one from a plugin. And three name a target that should never be a canonical: an
address that redirects, one that carries noindex and one that answers 404. Each contradicts itself: it names, as the
page to show, an address that the site redirects, keeps out of results or no longer has.
A relative canonical
The eggless truffle page says <link rel="canonical" href="/cakes/eggless-truffle">. Google accepts it, but the same
template on a crawlable test copy of the site would then name the test copy’s own address as canonical. Write the
full address, https://punebakes.example/cakes/eggless-truffle, so that every copy of the template names the live page.
A page built by JavaScript adds one more rule. The best place for the canonical is the HTML the server sends; a script that sets it must set the same value, or the original HTML must leave it out (JavaScript SEO basics). The lesson on JavaScript SEO at the end of this module shows why.
Checkpoint
Check: A canonical Google can use
When signals disagree, Google picks its own canonical
The bakery’s cakes listing answers 200 at three addresses, and its signals name all three. The program lists every
signal it sends today, then makes the changes that point them all at the one address the bakery prefers, /cakes/: a
folder keeps its slash, and the preferred address carries no tracking parameter:
"""Which address does each signal of Pune Bakes' cakes listing name, and what makes them agree?
The listing answers 200 at three addresses. A crawl of the site counted the internal links to each one; the page's
canonical tag, the sitemap and the server's redirects are as they are today. The program lists every signal, says
whether they agree, and then makes the changes that point them all at one preferred address.
"""
SITE = {
"redirects": {}, # address -> where it redirects (none yet)
"canonical tag": "/cakes/?ref=menu", # what <link rel="canonical"> on the listing says
"sitemap": ["/cakes/"],
"internal links": {"/cakes": 46, "/cakes/": 3, "/cakes/?ref=menu": 1}, # links found on the site's pages
}
PREFERRED = "/cakes/" # the bakery's rule: a folder keeps its slash, and no tracking parameters
def votes(site):
"""(signal, the address it names) for every signal the listing sends."""
out = [("redirect", target) for target in site["redirects"].values()]
out.append(("canonical tag", site["canonical tag"]))
out += [("sitemap", address) for address in site["sitemap"]]
for address, count in site["internal links"].items():
out.append((f"{count} internal link{'s' * (count > 1)}", address))
return out
def report(title, site):
named = votes(site)
print(title)
for signal, address in named:
print(f" {signal:<18} {address}")
addresses = sorted({address for _, address in named})
if len(addresses) == 1:
print(f" Every signal names {addresses[0]}.")
else:
print(f" The signals name {len(addresses)} addresses: {', '.join(addresses)}. Google must choose.")
def agree(site, preferred):
"""The changes that make every signal name the preferred address."""
others = [a for a in site["internal links"] if a != preferred]
moved = sum(site["internal links"][a] for a in others)
changes = [f"301 from {a} to {preferred}" for a in others if "?" not in a]
changes.append(f"canonical tag: {preferred} instead of {site['canonical tag']}")
changes.append(f"{moved} internal links: {preferred} instead of {' or '.join(others)}")
fixed = {
"redirects": {a: preferred for a in others if "?" not in a},
"canonical tag": preferred,
"sitemap": [preferred],
"internal links": {preferred: sum(site["internal links"].values())},
}
return changes, fixed
report("Today:", SITE)
changes, fixed = agree(SITE, PREFERRED)
print("\nChanges:")
for n, change in enumerate(changes, 1):
print(f" {n}. {change}")
print()
report("After the changes:", fixed) Output
Today: canonical tag /cakes/?ref=menu sitemap /cakes/ 46 internal links /cakes 3 internal links /cakes/ 1 internal link /cakes/?ref=menu The signals name 3 addresses: /cakes, /cakes/, /cakes/?ref=menu. Google must choose. Changes: 1. 301 from /cakes to /cakes/ 2. canonical tag: /cakes/ instead of /cakes/?ref=menu 3. 47 internal links: /cakes/ instead of /cakes or /cakes/?ref=menu After the changes: redirect /cakes/ canonical tag /cakes/ sitemap /cakes/ 50 internal links /cakes/ Every signal names /cakes/.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_signals.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Today the canonical tag names /cakes/?ref=menu, the sitemap names /cakes/, and 46 of the site’s 50 links name
/cakes. With three answers to one question, Google decides for itself, and Search Console reports it in the
Page indexing report: The Search Console report that lists a site's pages Google has and has not indexed, with a reason for each page left out, such as a robots.txt block, a noindex rule or a duplicate. More in “How Google Search works: crawl, index, serve”
(Page indexing report):
| Reason in the report | What it means | What to do |
|---|---|---|
| Duplicate, Google chose different canonical than user | The page is marked as canonical, but Google thinks another address makes a better one | Inspect it, compare both canonicals, fix the signals |
| Duplicate without user-selected canonical | A copy that names no canonical; Google chose another address | Usually nothing; name a canonical if Google chose wrong |
| Alternate page with proper canonical tag | A copy that names its indexed canonical correctly | Nothing |
The first reason sends you to the URL Inspection tool: The Search Console tool that shows what Google has stored about one URL, from its last crawl to the canonical Google chose, and can test the live page now or ask for it to be indexed. More in “Set up a site for search and Search Console”, which shows the user-declared canonical beside the Google-selected canonical: The address that URL Inspection reports as the canonical Google chose for a page's group of duplicates. It can differ from the user-declared canonical, the one the site names.; Google’s choice can be read only in the indexed result, never in a live test (URL Inspection tool). Step through the bakery’s case:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The report. The Page indexing report lists /cakes/?ref=menu under "Duplicate, Google chose different canonical than user". The listing's canonical tags name that address, but Google thinks another one makes a better canonical.
- Inspect the address. The bakery's owner pastes /cakes/?ref=menu into URL Inspection. It shows both choices side by side; the Google-selected canonical can be read only in the indexed result, not in a live test.
- Two canonicals. User-declared canonical, /cakes/?ref=menu; Google-selected canonical, /cakes/. Google chose the address the sitemap names over the tracking address the tags name.
- Read it as evidence. Google's choice is not a penalty. Here it is even the better address, so the owner fixes the signals to agree with it rather than fighting it.
- Make every signal agree. The canonical tags change to /cakes/, 47 internal links follow, and /cakes answers with one 301 to /cakes/. The sitemap already lists /cakes/.
- Ask for a recrawl, once. The owner requests indexing of /cakes/ in URL Inspection. There is a daily limit, so it is kept for the most important addresses and is never repeated for the same one.
- Wait for the recrawl. Googlebot fetches the listing again and finds every signal naming /cakes/. The report changes only after the recrawl, so the owner waits instead of changing the tags back and forth.
Here Google’s choice was the better address, and the fix was to make the site agree with it. When Google’s choice is the wrong one, Google’s troubleshooting guide gives the same order of work (Fix canonicalization issues): check which canonical Google chose and whether it serves searchers better; look for faulty tags, server faults and missing hreflang; make pages that should not be grouped clearly different, since Google may keep pages grouped as duplicates for up to two weeks after such a change; then request indexing for the most important addresses, because the requests have a daily limit. Changing the signals back and forth gives Google a new mix to weigh at every recrawl.
Indexability Checker Check one address of your own site: its status, redirects, robots.txt, noindex and the canonical it names.Checkpoint
Check: When Google chooses another canonical
Practise: plan one product’s 15 addresses
Spice Box’s Kashmiri chilli powder answers at 15 addresses, and two of today’s redirects form chains. Write plan() so
that every address gets either one 301 straight to its page or a canonical tag that names it; the sample tests check
that every redirect reaches a page in one hop and that tracking links and the print view keep working:
Exercise · Medium · Python
Plan the canonicals and redirects of 15 addresses
Spice Box's Kashmiri chilli powder answers at 15 addresses. CRAWL in canonical_map.py lists each one as a crawl found it, with its status today and, for a redirect, where it points today. Two of today's redirects form chains.
Write plan() so that it returns two dictionaries, redirects (address → target) and canonicals (address → the address its canonical tag names), with every address in exactly one of them:
- The preferred address of a page is https, without
www, in lower case, without a slash at the end of a product, without tracking parameters (utm_*,gclid,fbclid,ref) and without the/printfolder; anitem.phpaddress becomes the path of its product inOLD_IDS. Alangparameter changes the page, so it stays. - The preferred address of each page answers 200 and names itself as its canonical.
- Tracking links and the print view must keep working as they are: they answer 200 with a canonical tag that names their page's preferred address.
- Every other address gets one 301 straight to its page's preferred address, including the addresses that redirect today, so that no redirect leads to another redirect.
The sample tests check that every address is planned once, that only the product and its Hindi page name themselves, that every redirect reaches a page in one hop, and that tracking links and the print view keep working.
Starter code · canonical_map.py
from urllib.parse import parse_qsl, urlencode, urlsplit
SITE = "https://spicebox.example"
TRACKING = ("utm_", "gclid", "fbclid", "ref") # parameters that never change what the page shows
OLD_IDS = {"71": "/spices/kashmiri-chilli"} # the old shop's item.php addresses
P = SITE + "/spices/kashmiri-chilli"
# address as the crawl found it, its status today, and where it redirects today (None: it does not)
CRAWL = [
(P, 200, None),
("http://spicebox.example/spices/kashmiri-chilli", 301, "https://www.spicebox.example/spices/kashmiri-chilli"),
("https://www.spicebox.example/spices/kashmiri-chilli", 301, P),
("http://www.spicebox.example/spices/kashmiri-chilli", 301, P),
(P + "/", 200, None),
(SITE + "/Spices/Kashmiri-Chilli", 200, None),
(P + "?utm_source=newsletter&utm_campaign=diwali", 200, None),
(P + "?gclid=Cj0KCQiA", 200, None),
(P + "?ref=homepage", 200, None),
(SITE + "/print/spices/kashmiri-chilli", 200, None),
(SITE + "/item.php?id=71", 200, None),
(P + "?lang=hi", 200, None),
(P + "?utm_source=whatsapp&lang=hi", 200, None),
(SITE + "/spices/chilli-kashmiri", 301, SITE + "/spices/kashmiri-chilli-powder"),
(SITE + "/spices/kashmiri-chilli-powder", 301, P),
]
def plan():
"""Return (redirects, canonicals): address -> 301 target, and address -> the address its canonical tag names."""
redirects = {address: target for address, status, target in CRAWL if status == 301}
canonicals = {address: address for address, status, _ in CRAWL if status == 200}
return redirects, canonicals The sample tests · test_canonical_map.py
from canonical_map import CRAWL, SITE, plan
PRODUCT = SITE + "/spices/kashmiri-chilli"
HINDI = PRODUCT + "?lang=hi"
KEEP = [a for a, _, _ in CRAWL if "/print/" in a or any(p in a for p in ("utm_", "gclid=", "ref="))]
def test_every_address_planned_once():
"""puts each of the 15 addresses in redirects or in canonicals, never in both"""
redirects, canonicals = plan()
addresses = {a for a, _, _ in CRAWL}
assert set(redirects) | set(canonicals) == addresses
assert not set(redirects) & set(canonicals)
def test_only_two_pages_name_themselves():
"""the product and its Hindi page are the only addresses that are their own canonical"""
_, canonicals = plan()
assert {a for a, c in canonicals.items() if a == c} == {PRODUCT, HINDI}
def test_redirects_reach_a_page_in_one_hop():
"""every 301 goes straight to a page that answers 200 and names itself"""
redirects, canonicals = plan()
for address, target in redirects.items():
assert target not in redirects, f"{address} leads to another redirect"
assert canonicals.get(target) == target, f"{address} goes to {target}, which is not a page"
def test_copies_nobody_needs_are_redirected():
"""the other hosts, the slash, the capitals, item.php and the old names all get a 301 to the product"""
redirects, _ = plan()
for path in ["http://spicebox.example/spices/kashmiri-chilli", PRODUCT + "/", SITE + "/Spices/Kashmiri-Chilli",
SITE + "/item.php?id=71", SITE + "/spices/chilli-kashmiri"]:
assert redirects.get(path) == PRODUCT, path
def test_tracking_links_and_print_view_keep_working():
"""tracking links and the print view answer 200 with a canonical that names their page"""
redirects, canonicals = plan()
assert len(KEEP) == 5
for address in KEEP:
assert address not in redirects, address
assert canonicals[address] == (HINDI if "lang=hi" in address else PRODUCT), address A hint
Follow today's redirects to the address they finally reach before you work out the preferred address, so that chilli-kashmiri, which points to kashmiri-chilli-powder, which points to the product, goes straight to the product. Then ask one question of each address: is it the preferred address itself, a copy that must keep working (tracking parameters or /print/), or a copy nobody needs, which gets the 301?
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then practise the five ideas of this lesson, starting with the one your answers show is weakest:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): does Google penalise duplicate content?
Not as such. Google says some duplicate content on a site is normal, and its starter guide says duplicate content is not a violation of its spam policies and will not cause a manual action. What Google does is group the copies and show one address of each group, the canonical, which it chooses from the site’s signals: redirects, canonical tags, the sitemap, HTTPS and internal links. The real costs are control and waste: if the signals disagree, Google may show an address the site did not want, and crawling the copies takes time that new pages could use. Copying content from other sites or publishing near-identical pages to catch many searches is a different matter, which the spam policies cover. So the fix for ordinary duplicates is a redirect or a canonical, never a fear of penalties.
Two more questions follow, about a shop whose filter addresses Google chose as canonical and a product sold in three categories. Make the first decision of each before you open it:
Checkpoint
Your first decision
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Search Console reports "Duplicate, Google chose different canonical than user" for 180 product pages of a fashion shop, and Google chose their ?colour= addresses instead. How do you investigate, and what do you change?
-
Fresher to mid
A bakery's shop software gives a cake one address per category it is in, so the rasmalai cake answers at /birthday/rasmalai-cake, /eggless/rasmalai-cake and /anniversary/rasmalai-cake. How do you choose the canonical, and what else has to change?
Key takeaways
- Google groups pages with the same main content and shows one address of each group, the canonical; duplicates are normal and not a penalty, and a page in another language is not a duplicate.
- Redirects and canonical tags are strong signals and a sitemap is a weak one; HTTPS and internal links count too, and robots.txt, noindex and the removal tool are not ways to choose a canonical.
- A copy nobody needs gets one 301; a copy that must keep working, such as a tracking link or a print view, keeps its canonical tag; the preferred page names itself.
- A canonical counts only in a valid head, written as a full address, declared once, and naming a page that answers 200 without noindex.
- When signals disagree, Google chooses for itself: URL Inspection shows the declared and the selected canonical, and the fix is to make every signal name one address, then wait for the recrawl.
Terms in this lesson
- canonicalisation
- How Google picks one representative address from a group of pages with the same main content. A site can state its choice with redirects, canonical tags and its sitemap, but Google treats the choice as a hint.
- duplicate content
- The same or nearly the same main content at more than one address. Within a site it is normal and not a spam-policy violation: Google groups the copies and shows one address of each group.
- Google-selected canonical
- The address that URL Inspection reports as the canonical Google chose for a page's group of duplicates. It can differ from the user-declared canonical, the one the site names.
- rel=canonical
- A link element in a page's head, or a Link header for files such as PDFs, that names the address a site prefers for a group of duplicate pages. Google counts it as a strong signal, not a rule.
- self-referencing canonical
- A canonical tag on the preferred page that names the page itself, so that copies created by tracking or sorting parameters still name the right address.
- tracking parameter
- A query parameter such as utm_source, gclid or fbclid that records where a visit came from and does not change what the page shows. It belongs in links from outside, never in a site's own links or sitemap.
References
- What is URL canonicalization (Google Search Central)
- How to specify a canonical URL with rel="canonical" and other methods (Google Search Central)
- Fix canonicalization issues (Google Search Central)
- Use valid HTML to specify page metadata (Google Search Central)
- URL structure best practices for Google Search (Google Search Central)
- Understand the JavaScript SEO basics (Google Search Central)
- SEO Starter Guide (Google Search Central)
- Page indexing report (Google Search Console Help)
- URL Inspection tool (Google Search Console Help)
- RFC 6596: The Canonical Link Relation (IETF)
- RFC 3986: Uniform Resource Identifier (URI): Generic Syntax (section 6, normalization and comparison) (IETF)
- html.parser: simple HTML and XHTML parser (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress