<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[SEOtto Blog]]></title><description><![CDATA[SEOtto Blog]]></description><link>https://seotto.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 10:03:17 GMT</lastBuildDate><atom:link href="https://seotto.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How to find orphan pages by comparing your sitemap to internal links]]></title><description><![CDATA[An orphan page is a page that exists, often listed in your sitemap, but that no other page on your site links to. Visitors can't navigate to it, and search…]]></description><link>https://seotto.hashnode.dev/how-to-find-orphan-pages-by-comparing-your-sitemap-to-internal-links</link><guid isPermaLink="true">https://seotto.hashnode.dev/how-to-find-orphan-pages-by-comparing-your-sitemap-to-internal-links</guid><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Python]]></category><category><![CDATA[crawling]]></category><dc:creator><![CDATA[seotto.com]]></dc:creator><pubDate>Sat, 31 Oct 2026 22:47:37 GMT</pubDate><content:encoded><![CDATA[<p>An orphan page is a page that exists, often listed in your sitemap, but that no other page on your site links to. Visitors can't navigate to it, and search engines see it as unimportant because nothing points at it. Orphans pile up after redesigns, menu changes, campaign landing pages and CMS migrations.</p>
<p>The method to find them is simple: get the set of URLs you <strong>say</strong> exist (the sitemap), get the set of URLs your site <strong>actually links to</strong> (a crawl), and compare.</p>
<h2>Why orphans matter</h2>
<ul>
<li><strong>Discovery</strong>: crawlers find most pages by following links. A sitemap entry helps, but a page with no internal links is a weak candidate for crawling and ranking.</li>
<li><strong>Signals</strong>: internal links pass context and importance. An orphan gets none.</li>
<li><strong>Waste</strong>: some orphans are pages that should have been removed, like an old pricing page with outdated numbers that's still indexed.</li>
</ul>
<h2>Step 1: collect sitemap URLs</h2>
<pre><code class="language-python">import xml.etree.ElementTree as ET, requests

NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
UA = {"User-Agent": "orphan-finder/1.0"}

def sitemap_urls(url):
    root = ET.fromstring(requests.get(url, headers=UA, timeout=20).content)
    if root.tag.endswith("sitemapindex"):
        for loc in root.findall("sm:sitemap/sm:loc", NS):
            yield from sitemap_urls(loc.text.strip())
    else:
        for loc in root.findall("sm:url/sm:loc", NS):
            yield loc.text.strip()
</code></pre>
<h2>Step 2: crawl internal links</h2>
<p>A small breadth-first crawler starting at the homepage, staying on your host:</p>
<pre><code class="language-python">from collections import deque
from html.parser import HTMLParser
from urllib.parse import urljoin, urldefrag, urlparse
import time

class Links(HTMLParser):
    def __init__(self):
        super().__init__(); self.hrefs = []
    def handle_starttag(self, tag, attrs):
        if tag == "a":
            href = dict(attrs).get("href")
            if href: self.hrefs.append(href)

def normalize(u):
    u, _ = urldefrag(u)
    p = urlparse(u)
    path = p.path or "/"
    return f"{p.scheme}://{p.netloc.lower()}{path}" + (f"?{p.query}" if p.query else "")

def crawl(start, limit=2000):
    host = urlparse(start).netloc
    seen, linked = {normalize(start)}, set()
    queue = deque([start])
    while queue and len(seen) &lt;= limit:
        url = queue.popleft()
        try:
            r = requests.get(url, headers=UA, timeout=20)
        except requests.RequestException:
            continue
        if "text/html" not in r.headers.get("Content-Type", ""):
            continue
        p = Links(); p.feed(r.text)
        for href in p.hrefs:
            abs_url = normalize(urljoin(r.url, href))
            if urlparse(abs_url).netloc != host:
                continue
            linked.add(abs_url)
            if abs_url not in seen:
                seen.add(abs_url); queue.append(abs_url)
        time.sleep(0.3)   # be polite to your own server
    return linked
</code></pre>
<p>For larger sites, also respect <code>robots.txt</code> and skip obvious non-pages (<code>/cart</code>, <code>/login</code>, search result URLs with query strings) to keep the crawl focused.</p>
<h2>Step 3: compare</h2>
<pre><code class="language-python">import sys

sitemap = {normalize(u) for u in sitemap_urls(sys.argv[1])}
start = sys.argv[2]
linked = crawl(start)

orphans = sorted(sitemap - linked - {normalize(start)})
unlisted = sorted(u for u in linked - sitemap if "?" not in u)

print(f"{len(sitemap)} in sitemap, {len(linked)} linked internally\n")
print("Orphans (in sitemap, never linked):")
for u in orphans: print("  ", u)
print("\nLinked but missing from sitemap:")
for u in unlisted[:50]: print("  ", u)
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">python orphans.py https://example.com/sitemap.xml https://example.com/
</code></pre>
<p>The second list is a useful bonus: pages your site links to that aren't in the sitemap, often new sections the sitemap generator doesn't know about.</p>
<h2>Normalization is everything</h2>
<p>Most false orphans come from URL mismatches, not real problems. If the sitemap lists <code>https://example.com/blog/post/</code> and your links use <code>/blog/post</code>, a naive comparison calls it an orphan. Before trusting the results:</p>
<ul>
<li>Match the trailing-slash style your site uses.</li>
<li>Lowercase the host (paths are case-sensitive, so leave them alone).</li>
<li>Decide whether <code>http://</code> links count; ideally your internal links use HTTPS already.</li>
<li>Strip tracking parameters such as <code>utm_*</code> from crawled links.</li>
</ul>
<p>If you see a large number of orphans, compare a few by hand first; it's usually a normalization rule.</p>
<h2>What to do with real orphans</h2>
<p>Go through the list and sort each page into one of three buckets:</p>
<ol>
<li><strong>Valuable and current</strong>: link to it from a relevant hub page, category or related articles. One or two contextual links from pages that themselves get traffic are enough.</li>
<li><strong>Outdated or duplicate</strong>: redirect it to its replacement with a 301, and remove it from the sitemap.</li>
<li><strong>Intentionally unlinked</strong> (campaign landing pages, thank-you pages): remove them from the sitemap, and add <code>noindex</code> if they shouldn't appear in search.</li>
</ol>
<h2>JavaScript navigation caveat</h2>
<p>This crawler reads server-rendered HTML. If your menus or related-post widgets are rendered client-side, links that exist for visitors may be invisible here, producing false orphans. Spot-check a few with the browser, or render pages with a headless browser for the crawl.</p>
<h2>Make it a habit</h2>
<p>Orphans appear gradually, so a quarterly check works well. For the faster-moving signals on the pages themselves, SEOtto finds pages through your XML sitemap or internal links and alerts you when status codes, titles, canonicals or indexing directives change: <a href="https://seotto.com">seotto.com</a>.</p>
<h2>Summary</h2>
<ul>
<li>Orphans = sitemap URLs with no internal links pointing to them.</li>
<li>Crawl from the homepage, collect internal links, and diff against the sitemap.</li>
<li>Normalize URLs carefully to avoid false positives.</li>
<li>Link, redirect, or de-list each real orphan.</li>
</ul>
<h2>Related reading</h2>
<ul>
<li><a href="https://seotto.hashnode.dev/how-to-check-http-to-https-and-www-redirects-are-set-up-right">How to check HTTP to HTTPS and www redirects are set up right</a></li>
<li><a href="https://seotto.hashnode.dev/how-to-check-open-graph-tags-so-link-previews-don-t-break">How to check Open Graph tags so link previews don't break</a></li>
<li><a href="https://dev.to/seotto/find-duplicate-and-missing-title-tags-with-a-python-crawl-script-4cfp">Find duplicate and missing title tags with a Python crawl script</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to check Open Graph tags so link previews don't break]]></title><description><![CDATA[When someone shares your page in Slack, LinkedIn, Facebook, Discord or a messaging app, the preview card (title, description, image) comes from Open Graph…]]></description><link>https://seotto.hashnode.dev/how-to-check-open-graph-tags-so-link-previews-don-t-break</link><guid isPermaLink="true">https://seotto.hashnode.dev/how-to-check-open-graph-tags-so-link-previews-don-t-break</guid><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[seotto.com]]></dc:creator><pubDate>Sat, 31 Oct 2026 22:46:24 GMT</pubDate><content:encoded><![CDATA[<p>When someone shares your page in Slack, LinkedIn, Facebook, Discord or a messaging app, the preview card (title, description, image) comes from Open Graph tags. When those tags are missing or wrong, the card shows a blank image, the wrong title, or a preview of your homepage for every article. It doesn't affect rankings directly, but it decides whether people click a shared link.</p>
<p>This guide covers the tags that matter, a script to audit them, and the caching quirks that make fixes look like they didn't work.</p>
<h2>The tags that matter</h2>
<pre><code class="language-html">&lt;meta property="og:title" content="How to check HTTPS redirects"&gt;
&lt;meta property="og:description" content="Test all four host and scheme variants in one minute."&gt;
&lt;meta property="og:image" content="https://example.com/og/https-redirects.png"&gt;
&lt;meta property="og:url" content="https://example.com/blog/https-redirects"&gt;
&lt;meta property="og:type" content="article"&gt;
&lt;meta name="twitter:card" content="summary_large_image"&gt;
</code></pre>
<p>Rules that trip people up:</p>
<ul>
<li><code>og:image</code> <strong>must be an absolute URL.</strong> <code>/og/image.png</code> doesn't work in most consumers.</li>
<li><strong>The image must be publicly reachable</strong>: no login, no hotlink protection, no <code>robots.txt</code> block on the image path.</li>
<li><strong>Size</strong>: 1200×630 pixels is the common recommendation for large cards. Very small images may be shown as a thumbnail or not at all.</li>
<li><code>og:url</code> <strong>should match the canonical URL</strong>, otherwise shares of the same page get split across URLs.</li>
<li><strong>One of each.</strong> Duplicate <code>og:title</code> tags (one from the theme, one from a plugin) leave the result up to each platform.</li>
</ul>
<p>X (Twitter) falls back to Open Graph for title, description and image, but needs <code>twitter:card</code> to choose the large image layout.</p>
<h2>Common failures</h2>
<ul>
<li>Every page has the same <code>og:title</code> and <code>og:image</code> because the tags are hard-coded in the layout.</li>
<li>Article pages have tags, but category or product pages fall back to the homepage values.</li>
<li>The image URL points to a staging domain or a CDN path that changed.</li>
<li>Tags are injected by JavaScript. Most preview crawlers don't run JavaScript, so they see nothing.</li>
<li>The image file is several megabytes and times out in some crawlers.</li>
</ul>
<h2>Audit tags with a script</h2>
<p>This script fetches the raw HTML (what preview crawlers see), reads the tags, and checks the image:</p>
<pre><code class="language-python"># og_check.py  usage: python og_check.py urls.txt
import sys, requests
from html.parser import HTMLParser

UA = {"User-Agent": "Mozilla/5.0 (og-check)"}
REQUIRED = ["og:title", "og:description", "og:image", "og:url"]

class Meta(HTMLParser):
    def __init__(self):
        super().__init__(); self.tags = {}; self.canonical = None
    def handle_starttag(self, tag, a):
        a = dict(a)
        if tag == "meta":
            key = a.get("property") or a.get("name")
            if key and key.startswith(("og:", "twitter:")):
                self.tags.setdefault(key, []).append(a.get("content", ""))
        if tag == "link" and a.get("rel") == "canonical":
            self.canonical = a.get("href")

def check(url):
    issues = []
    r = requests.get(url, headers=UA, timeout=20)
    m = Meta(); m.feed(r.text)
    for key in REQUIRED:
        values = m.tags.get(key, [])
        if not values or not values[0].strip():
            issues.append(f"missing {key}")
        elif len(values) &gt; 1:
            issues.append(f"duplicate {key} ({len(values)}x)")
    img = (m.tags.get("og:image") or [""])[0]
    if img:
        if not img.startswith("http"):
            issues.append(f"og:image not absolute: {img}")
        else:
            ir = requests.get(img, headers=UA, timeout=20, stream=True)
            size = int(ir.headers.get("Content-Length") or 0)
            ctype = ir.headers.get("Content-Type", "")
            ir.close()
            if ir.status_code != 200:
                issues.append(f"og:image returns {ir.status_code}")
            elif not ctype.startswith("image/"):
                issues.append(f"og:image content-type is {ctype}")
            elif size &gt; 5_000_000:
                issues.append(f"og:image is {size // 1_000_000} MB")
    og_url = (m.tags.get("og:url") or [""])[0]
    if m.canonical and og_url and og_url != m.canonical:
        issues.append(f"og:url {og_url} != canonical {m.canonical}")
    return issues, (m.tags.get("og:title") or [""])[0]

titles = {}
for line in open(sys.argv[1]):
    u = line.strip()
    if not u: continue
    issues, title = check(u)
    titles.setdefault(title, []).append(u)
    print(f"{u}\t{'; '.join(issues) or 'ok'}")

for title, urls in titles.items():
    if title and len(urls) &gt; 1:
        print(f"\nSame og:title on {len(urls)} pages: '{title}'")
</code></pre>
<p>The last block flags pages sharing an identical <code>og:title</code>, which usually means a template is using a site-wide default.</p>
<h2>Run it on one page per template</h2>
<pre><code class="language-bash">python og_check.py urls.txt
</code></pre>
<pre><code class="language-text">https://example.com/	ok
https://example.com/blog/https-redirects	ok
https://example.com/products/mug	missing og:description; og:image returns 404

Same og:title on 2 pages: 'Example Store'
</code></pre>
<h2>Why fixes don't show up right away</h2>
<p>Platforms cache previews, sometimes for days. After fixing tags:</p>
<ul>
<li><strong>Facebook</strong>: use the Sharing Debugger and click "Scrape Again".</li>
<li><strong>LinkedIn</strong>: the Post Inspector refreshes its cache for the URL.</li>
<li><strong>Slack and Discord</strong>: cache per URL; adding a harmless query string (<code>?v=2</code>) shows the new preview for testing.</li>
</ul>
<p>If the platform's debugger still shows old values, check that it's not hitting a cached version of your page at the CDN.</p>
<h2>Keep previews from regressing</h2>
<p>Theme updates and plugin changes are the usual culprits when previews break months after they were fixed. SEOtto includes Open Graph among the checks it runs on each crawl of your site, alongside titles, descriptions and canonicals, and alerts you when they change: <a href="https://seotto.com">seotto.com</a>.</p>
<h2>Summary</h2>
<ul>
<li>Set <code>og:title</code>, <code>og:description</code>, absolute <code>og:image</code>, <code>og:url</code> and <code>twitter:card</code>.</li>
<li>Render tags server-side; preview crawlers rarely run JavaScript.</li>
<li>Check image status, type and size, and avoid duplicate tags.</li>
<li>Use platform debuggers to clear cached previews.</li>
</ul>
<h2>Related reading</h2>
<ul>
<li><a href="https://dev.to/seotto/how-to-check-hreflang-tags-for-errors-with-a-script-5120">How to check hreflang tags for errors (with a script)</a></li>
<li><a href="https://seotto.hashnode.dev/how-to-check-http-to-https-and-www-redirects-are-set-up-right">How to check HTTP to HTTPS and www redirects are set up right</a></li>
<li><a href="https://telegra.ph/A-weekly-technical-SEO-check-routine-for-agency-client-sites-10-31">A weekly technical SEO check routine for agency client sites</a></li>
<li><a href="https://telegra.ph/Why-your-XML-sitemap-suddenly-has-fewer-URLs-10-31">Why your XML sitemap suddenly has fewer URLs</a></li>
<li><a href="https://telegra.ph/Did-your-meta-descriptions-change-Why-its-worth-noticing-10-31">Did your meta descriptions change? Why it's worth noticing</a></li>
<li><a href="https://seotto.hashnode.dev/how-to-find-orphan-pages-by-comparing-your-sitemap-to-internal-links">How to find orphan pages by comparing your sitemap to internal links</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to check HTTP to HTTPS and www redirects are set up right]]></title><description><![CDATA[Every site has at least four ways to be reached: and Only one should be the real address. The other three should send visitors and crawlers there with a…]]></description><link>https://seotto.hashnode.dev/how-to-check-http-to-https-and-www-redirects-are-set-up-right</link><guid isPermaLink="true">https://seotto.hashnode.dev/how-to-check-http-to-https-and-www-redirects-are-set-up-right</guid><category><![CDATA[SEO]]></category><category><![CDATA[nginx]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[seotto.com]]></dc:creator><pubDate>Sat, 31 Oct 2026 22:45:34 GMT</pubDate><content:encoded><![CDATA[<p>Every site has at least four ways to be reached: <code>http://example.com</code>, <code>http://www.example.com</code>, <code>https://example.com</code> and <code>https://www.example.com</code>. Only one should be the real address. The other three should send visitors and crawlers there with a single permanent redirect. When this is misconfigured, you get duplicate URLs, diluted signals, extra redirect hops, and sometimes a version of the site that's still served without HTTPS.</p>
<p>This guide shows how to test all four variants quickly, what correct looks like, and how to fix the common problems.</p>
<h2>What "correct" looks like</h2>
<p>Pick a canonical host (with or without <code>www</code>, it doesn't matter which) and make sure:</p>
<ul>
<li>All three non-canonical variants return <code>301</code> (or <code>308</code>) <strong>directly</strong> to the canonical HTTPS URL.</li>
<li>Each redirect is a single hop, not <code>http://www</code> → <code>https://www</code> → <code>https://</code>.</li>
<li>The path and query string are preserved: <code>http://example.com/pricing?plan=pro</code> lands on <code>https://example.com/pricing?plan=pro</code>, not the homepage.</li>
<li>The canonical URL returns <code>200</code>.</li>
</ul>
<h2>Test all four variants with curl</h2>
<pre><code class="language-bash">#!/usr/bin/env bash
# redirect-check.sh  usage: ./redirect-check.sh example.com /some/path
domain=$1; path=${2:-/}
for url in "http://$domain$path" "http://www.$domain$path" "https://$domain$path" "https://www.$domain$path"; do
  echo "== $url"
  curl -s -o /dev/null -L --max-redirs 5 \
    -w '%{num_redirects} hop(s) -&gt; %{url_effective} [%{http_code}]\n' "$url"
  curl -sI "$url" | grep -iE '^(HTTP|location)' | head -2
done
</code></pre>
<p>Example output for a healthy site:</p>
<pre><code class="language-text">== http://example.com/pricing
1 hop(s) -&gt; https://example.com/pricing [200]
HTTP/1.1 301 Moved Permanently
Location: https://example.com/pricing
== http://www.example.com/pricing
1 hop(s) -&gt; https://example.com/pricing [200]
...
== https://example.com/pricing
0 hop(s) -&gt; https://example.com/pricing [200]
</code></pre>
<p>Always test with a real path, not just <code>/</code>. A common bug is a redirect rule that sends every variant to the homepage.</p>
<h2>Problems you'll find</h2>
<p><strong>Two hops instead of one.</strong> <code>http://www.example.com</code> → <code>https://www.example.com</code> → <code>https://example.com</code>. It works, but each hop costs time and crawl budget. Usually the HTTPS redirect and the host redirect live in different layers (CDN and server) and run in sequence.</p>
<p><strong>302 instead of 301.</strong> A temporary redirect says "the old URL is still the real one". For a permanent host or scheme change, use 301 or 308.</p>
<p><strong>A variant serves content.</strong> <code>https://www.example.com</code> returns <code>200</code> with the full site instead of redirecting. Now two hosts serve identical pages. A canonical tag helps search engines choose, but a redirect is the clear signal.</p>
<p><strong>Certificate errors on the non-canonical host.</strong> If the certificate only covers <code>example.com</code>, then <code>https://www.example.com</code> fails with a TLS error before any redirect can happen. Visitors who type <code>www</code> see a warning page. The certificate must cover every hostname you redirect from.</p>
<p><strong>Path dropped.</strong> All variants land on <code>/</code>. Old links to deep pages lose their target.</p>
<h2>Fix it in Nginx</h2>
<p>Handle every non-canonical variant in one <code>server</code> block that redirects directly:</p>
<pre><code class="language-nginx"># Redirect all HTTP, and HTTPS www, to https://example.com
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name www.example.com;
    ssl_certificate     /etc/ssl/example.com/fullchain.pem;   # must include www
    ssl_certificate_key /etc/ssl/example.com/privkey.pem;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com;
    # ... the real site ...
}
</code></pre>
<p><code>$request_uri</code> keeps the path and query string. Using <code>return</code> rather than <code>rewrite</code> is simpler and faster.</p>
<h2>Fix it in Apache (.htaccess)</h2>
<pre><code class="language-apache">RewriteEngine On
RewriteCond %{HTTPS} off [OR]
RewriteCond %{HTTP_HOST} ^www\. [NC]
RewriteRule ^ https://example.com%{REQUEST_URI} [L,R=301]
</code></pre>
<p>The two conditions combined with <code>[OR]</code> produce a single redirect for any non-canonical request. Behind a proxy or CDN that terminates TLS, <code>%{HTTPS}</code> is always off; check <code>%{HTTP:X-Forwarded-Proto}</code> instead or you'll create a loop.</p>
<h2>If a CDN is in front</h2>
<p>Decide which layer owns the redirects. If the CDN handles "always use HTTPS" and your origin handles <code>www</code> removal, you get two hops. Either put both rules in the CDN (a single redirect rule matching all variants) or both at the origin. Avoid duplicating rules in both places.</p>
<h2>Add HSTS once it's stable</h2>
<p>After the redirects are correct, an <code>HSTS</code> header tells browsers to use HTTPS directly on future visits, skipping the HTTP hop:</p>
<pre><code class="language-nginx">add_header Strict-Transport-Security "max-age=31536000" always;
</code></pre>
<p>Start with a short <code>max-age</code> while testing; browsers remember the setting, so mistakes are sticky.</p>
<h2>Re-check after infrastructure changes</h2>
<p>Redirect setups break when you move hosts, change CDNs, renew certificates or touch server configs. Running the script after such changes takes a minute. SEOtto also checks redirects (https, www/non-www) and SSL certificates on every crawl of your site and alerts when something changes: <a href="https://seotto.com">seotto.com</a>.</p>
<h2>Summary</h2>
<ul>
<li>Test all four scheme and host variants, with a real path.</li>
<li>Expect one 301 hop straight to the canonical HTTPS URL, path preserved.</li>
<li>Make sure the certificate covers every hostname.</li>
<li>Keep redirect rules in one layer, then add HSTS.</li>
</ul>
<h2>Related reading</h2>
<ul>
<li><a href="https://dev.to/seotto/find-duplicate-and-missing-title-tags-with-a-python-crawl-script-4cfp">Find duplicate and missing title tags with a Python crawl script</a></li>
<li><a href="https://dev.to/seotto/how-to-check-hreflang-tags-for-errors-with-a-script-5120">How to check hreflang tags for errors (with a script)</a></li>
<li><a href="https://telegra.ph/A-weekly-technical-SEO-check-routine-for-agency-client-sites-10-31">A weekly technical SEO check routine for agency client sites</a></li>
<li><a href="https://telegra.ph/Why-your-XML-sitemap-suddenly-has-fewer-URLs-10-31">Why your XML sitemap suddenly has fewer URLs</a></li>
<li><a href="https://telegra.ph/Did-your-meta-descriptions-change-Why-its-worth-noticing-10-31">Did your meta descriptions change? Why it's worth noticing</a></li>
<li><a href="https://seotto.hashnode.dev/how-to-find-orphan-pages-by-comparing-your-sitemap-to-internal-links">How to find orphan pages by comparing your sitemap to internal links</a></li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Sitemap 'Couldn't fetch' in Search Console: what it means and how to fix it]]></title><description><![CDATA[Few Search Console messages are as unhelpful as "Couldn't fetch" next to your sitemap. It doesn't say why, and your server logs often show nothing wrong…]]></description><link>https://seotto.hashnode.dev/sitemap-couldn-t-fetch-in-search-console-what-it-means-and-how-to-fix-it</link><guid isPermaLink="true">https://seotto.hashnode.dev/sitemap-couldn-t-fetch-in-search-console-what-it-means-and-how-to-fix-it</guid><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[seotto.com]]></dc:creator><pubDate>Fri, 30 Oct 2026 20:04:16 GMT</pubDate><content:encoded><![CDATA[<p>Few Search Console messages are as unhelpful as <strong>"Couldn't fetch"</strong> next to your sitemap. It doesn't say why, and your server logs often show nothing wrong. Here is what's going on behind it and a checklist to get your sitemap read again.</p>
<h2>Why a broken sitemap matters</h2>
<p>Google can find pages through links, so a broken sitemap rarely deindexes a site. What it does is slow discovery: new pages, updated pages and pages with few internal links get found later, or not at all. On large or fast-changing sites, that delay costs traffic.</p>
<h2>First, a quirk: "Couldn't fetch" is sometimes just "pending"</h2>
<p>When you submit a new sitemap, Search Console sometimes shows "Couldn't fetch" while it simply hasn't tried yet. If the sitemap is brand new and passes every check below, wait a day or two before digging deeper. If it persists, or an existing sitemap flips to it, work through the list.</p>
<h2>1. Does the URL return 200?</h2>
<pre><code class="language-bash">curl -sI https://example.com/sitemap.xml
</code></pre>
<p>You want <code>HTTP/2 200</code> (or <code>HTTP/1.1 200 OK</code>). Common failures:</p>
<ul>
<li><strong>404</strong> after a plugin change or migration. The sitemap moved, say from <code>/sitemap.xml</code> to <code>/sitemap_index.xml</code>, and the old URL is what you submitted.</li>
<li><strong>301/302</strong> to another URL. Google follows redirects, but redirect chains, especially to another host, cause trouble. Submit the final URL.</li>
<li><strong>5xx or a timeout.</strong> Large generated sitemaps can time out on slow servers. Cache them as static files.</li>
<li><strong>403</strong> from a firewall or bot protection that treats Googlebot as a bot to block. Check your CDN or WAF rules.</li>
</ul>
<h2>2. Is it blocked by robots.txt?</h2>
<p>If robots.txt disallows the sitemap's path, Google won't fetch it. Check for rules like <code>Disallow: /sitemap</code> or <code>Disallow: /*.xml$</code>. While you're there, add the sitemap location:</p>
<pre><code class="language-plaintext">Sitemap: https://example.com/sitemap.xml
</code></pre>
<h2>3. Is it really XML?</h2>
<p>Open the sitemap URL with curl rather than a browser, which may hide problems:</p>
<pre><code class="language-bash">curl -s https://example.com/sitemap.xml | head -5
</code></pre>
<p>You should see something like:</p>
<pre><code class="language-xml">&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&gt;
</code></pre>
<p>Red flags:</p>
<ul>
<li>An HTML page instead of XML (a login page, a cookie wall, a "not found" template served with 200).</li>
<li>Whitespace or a PHP warning before <code>&lt;?xml</code>, which makes the file invalid.</li>
<li>Unescaped <code>&amp;</code> in URLs. It must be written <code>&amp;amp;</code>.</li>
</ul>
<h2>4. Is it within the limits?</h2>
<p>A single sitemap may contain at most <strong>50,000 URLs</strong> and be at most <strong>50 MB</strong> uncompressed. Bigger sites need a sitemap index that points to several sitemaps. Each child sitemap must pass the same checks, and a single broken child shows up as an error on the index.</p>
<h2>5. Are the URLs in it the right ones?</h2>
<p>A sitemap that loads fine can still be wrong:</p>
<ul>
<li>URLs on the wrong host or protocol (<code>http://</code> or a staging domain).</li>
<li>URLs that redirect, return 404, or are noindexed.</li>
<li>URLs whose canonical points elsewhere.</li>
</ul>
<p>Google treats these as noise. A sitemap should list only canonical, indexable pages that return 200.</p>
<h2>6. The silent one: a sitemap that shrinks</h2>
<p>This failure never shows an error. A deploy or plugin update changes how the sitemap is generated, and it goes from 4,000 URLs to 400, or to an empty <code>&lt;urlset&gt;</code>. The file still returns 200 and validates, so Search Console reports "Success". Only the "Discovered URLs" count tells the story, if anyone looks.</p>
<p>A quick way to count URLs:</p>
<pre><code class="language-bash">curl -s https://example.com/sitemap.xml | grep -c "&lt;loc&gt;"
</code></pre>
<p>Record that number and compare it after each release.</p>
<h2>After the fix</h2>
<ol>
<li>Confirm the URL returns 200 and valid XML.</li>
<li>In Search Console, open Sitemaps, remove the old entry if the URL changed, and submit the correct one.</li>
<li>Check back in a day or two for "Success" and a sensible "Discovered URLs" number.</li>
</ol>
<h2>Automate the boring part</h2>
<p>Sitemaps break the same way robots.txt does: quietly, during a deploy. If you'd rather not run curl by hand after every release, an SEO monitor can load the sitemap on every crawl and count its URLs. <a href="https://seotto.com">SEOtto</a>, for example, alerts when a sitemap fails to load, comes back empty, or loses 20% or more of its URLs. Whichever route you take, checking status code, format and URL count covers almost every "Couldn't fetch" you'll ever see.</p>
<h2>Related reading</h2>
<ul>
<li><a href="https://telegra.ph/Why-your-XML-sitemap-suddenly-has-fewer-URLs-10-31">Why your XML sitemap suddenly has fewer URLs</a></li>
<li><a href="https://dev.to/seotto/how-to-check-hreflang-tags-for-errors-with-a-script-5120">How to check hreflang tags for errors (with a script)</a></li>
<li><a href="https://seotto.hashnode.dev/how-to-check-open-graph-tags-so-link-previews-don-t-break">How to check Open Graph tags so link previews don't break</a></li>
<li><a href="https://telegra.ph/Did-your-meta-descriptions-change-Why-its-worth-noticing-10-31">Did your meta descriptions change? Why it's worth noticing</a></li>
</ul>
]]></content:encoded></item></channel></rss>