Dev.to WebDev 🛠 Dev 👁 0 📖 10 min read

How to Validate an XML Sitemap: Common Errors, What Google Ignores, and a Small Python Checker

Sitemaps are one of those files nobody looks at until something breaks. A deploy changes the URL structure, a plugin starts emitting relative paths, a query string sneaks in an unescaped &, and the sitemap quietly stops

Sitemaps are one of those files nobody looks at until something breaks. A deploy changes the URL structure, a plugin starts emitting relative paths, a query string sneaks in an unescaped &, and the sitemap quietly stops parsing. Search Console tells you eventually. A check in CI tells you before you ship.

This post covers what the sitemap protocol actually requires, which fields Google says it ignores, the errors that show up most often in real sitemaps, and a small Python script you can drop into a pipeline. Everything protocol-related below is from sitemaps.org and Google's Build and submit a sitemap page, as read on 24 Oct 2026.

Disclosure: Appie.run publishes this post. We mention our free sitemap tools near the end as an option if you'd rather not run a script; the code here works without them.

What the protocol requires

A minimal valid sitemap looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://www.example.com/foo.html</loc>
    <lastmod>2026-10-20</lastmod>
  </url>
</urlset>

The rules worth encoding in a checker:

  • Root element. Either <urlset> (a list of pages) or <sitemapindex> (a list of sitemaps), in the http://www.sitemaps.org/schemas/sitemap/0.9 namespace.
  • Encoding. The file must be UTF-8.
  • Size. No more than 50,000 URLs and no larger than 50MB (52,428,800 bytes) uncompressed per file. A sitemap index has the same limits for the number of sitemaps it lists.
  • <loc>. Required. Google asks for fully qualified, absolute URLs, and sitemaps.org says the value must be less than 2,048 characters.
  • Escaping. All values must be entity-escaped, so & becomes &amp;, and URLs must be URL-escaped.
  • <lastmod>. Optional, in W3C Datetime format: 2026-10-20 or 2026-10-20T10:00:00+00:00, for example.
  • <changefreq>. Optional, one of always, hourly, daily, weekly, monthly, yearly, never.
  • <priority>. Optional, from 0.0 to 1.0.
  • One host. sitemaps.org says all URLs in a sitemap must be from a single host, and that a sitemap's location limits which URLs it can include: a file at /catalog/sitemap.xml can list URLs under /catalog/, not the rest of the site.

What Google says it ignores (and what it uses)

This changes how much effort the optional fields deserve. Google's documentation says:

  • Google ignores <priority> and <changefreq>.
  • Google uses <lastmod> if it's consistently and verifiably accurate, for example when it matches the page's real last modification. Google says it should reflect the last significant update (main content, structured data, links), and that a copyright date change doesn't count.
  • Sitemaps should list the URLs you want to see in search results, which in practice means canonical URLs.
  • A sitemap at the site root can affect all files on the site, which is where Google recommends putting it. Placed elsewhere, it only affects descendants of its directory unless you submit it through Search Console.

The practical takeaway: don't spend time tuning priority values. Spend it making lastmod honest. A generator that stamps every URL with today's date on every build is telling Google nothing useful.

Other search engines may treat these fields differently, so keeping priority and changefreq valid costs nothing. Just don't expect them to move anything on Google.

The errors we see most

These are the problems that come up again and again when people paste sitemaps into a validator.

1. Relative URLs

<url><loc>/pricing</loc></url>

Usually a templating bug: the generator uses the path where it should use the full URL. Fix it with the absolute URL, including scheme and host.

2. Unescaped ampersands

<loc>https://example.com/search?q=shoes&page=2</loc>

This breaks the XML parser, which means the entire file fails, not one entry. It must be &amp;. If your sitemap is built with string concatenation instead of an XML library, this is the bug waiting to happen.

3. Invalid lastmod

<lastmod>20/10/2026</lastmod>
<lastmod>Oct 20, 2026</lastmod>

Both are invalid. Use YYYY-MM-DD or a full timestamp with a timezone.

4. Out-of-range priority or made-up changefreq

<priority>2</priority>
<changefreq>often</changefreq>

Google ignores both fields, but invalid values are still protocol errors and some tools will flag the file.

5. Duplicates and mixed hosts

The same URL listed twice, or a mix of example.com and www.example.com. Duplicates are harmless but noisy. Mixed hosts usually mean your canonical setup and your sitemap disagree, and that's worth fixing at the source.

6. Too big for one file

A growing catalogue crosses 50,000 URLs and nobody splits the file. Split it into several sitemaps and list them in a sitemap index.

7. Listing URLs you don't want indexed

Redirects, 404s, noindex pages and non-canonical variants. These aren't XML errors, so a schema check won't catch them. You need to request the URLs (see the optional step below).

A small Python checker

This script uses only the standard library. It takes a file path or a URL, checks the rules above, prints errors and warnings, and exits non-zero on errors so it can fail a CI job.

import sys
import urllib.request
from datetime import datetime
from urllib.parse import urlparse
import xml.etree.ElementTree as ET

NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
MAX_URLS = 50_000
MAX_BYTES = 52_428_800  # 50MB uncompressed


def load(source):
    if source.startswith(("http://", "https://")):
        req = urllib.request.Request(source, headers={"User-Agent": "sitemap-check/1.0"})
        with urllib.request.urlopen(req, timeout=30) as resp:
            return resp.read()
    with open(source, "rb") as f:
        return f.read()


def valid_w3c_date(value):
    for fmt in ("%Y", "%Y-%m", "%Y-%m-%d", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
        try:
            datetime.strptime(value.replace("Z", "+00:00"), fmt)
            return True
        except ValueError:
            pass
    return False


def check(source):
    raw = load(source)
    errors, warnings = [], []

    if len(raw) > MAX_BYTES:
        errors.append(f"File is {len(raw):,} bytes; the limit is {MAX_BYTES:,} uncompressed")
    try:
        raw.decode("utf-8")
    except UnicodeDecodeError:
        errors.append("File is not valid UTF-8")

    try:
        root = ET.fromstring(raw)
    except ET.ParseError as e:
        return [f"XML does not parse: {e}"], warnings

    if root.tag == NS + "sitemapindex":
        child, kind = "sitemap", "index"
    elif root.tag == NS + "urlset":
        child, kind = "url", "urlset"
    else:
        return [f"Root element is {root.tag}; expected urlset or sitemapindex in the sitemaps.org namespace"], warnings

    entries = root.findall(NS + child)
    if len(entries) > MAX_URLS:
        errors.append(f"{len(entries):,} entries; the limit is {MAX_URLS:,} per file")

    seen, hosts = set(), set()
    for i, entry in enumerate(entries, 1):
        loc_el = entry.find(NS + "loc")
        if loc_el is None or not (loc_el.text or "").strip():
            errors.append(f"Entry {i}: missing <loc>")
            continue
        loc = loc_el.text.strip()
        parsed = urlparse(loc)
        if parsed.scheme not in ("http", "https") or not parsed.netloc:
            errors.append(f"Entry {i}: <loc> is not an absolute URL: {loc}")
        if len(loc) >= 2048:
            errors.append(f"Entry {i}: <loc> is 2,048 characters or longer")
        if loc in seen:
            warnings.append(f"Entry {i}: duplicate URL {loc}")
        seen.add(loc)
        if parsed.netloc:
            hosts.add(parsed.netloc)

        lastmod = entry.find(NS + "lastmod")
        if lastmod is not None and not valid_w3c_date((lastmod.text or "").strip()):
            errors.append(f"Entry {i}: <lastmod> is not a W3C Datetime: {lastmod.text}")

        if kind == "urlset":
            cf = entry.find(NS + "changefreq")
            if cf is not None and (cf.text or "").strip() not in {
                "always", "hourly", "daily", "weekly", "monthly", "yearly", "never"
            }:
                warnings.append(f"Entry {i}: unexpected <changefreq> {cf.text}")
            pr = entry.find(NS + "priority")
            if pr is not None:
                try:
                    if not 0.0 <= float(pr.text) <= 1.0:
                        raise ValueError
                except (TypeError, ValueError):
                    warnings.append(f"Entry {i}: <priority> must be 0.0 to 1.0, got {pr.text}")

    if len(hosts) > 1:
        warnings.append(f"URLs span several hosts: {', '.join(sorted(hosts))}")

    print(f"{kind}: {len(entries):,} entries")
    return errors, warnings


if __name__ == "__main__":
    errs, warns = check(sys.argv[1])
    for e in errs:
        print("ERROR  ", e)
    for w in warns:
        print("WARNING", w)
    sys.exit(1 if errs else 0)

Run it against a file or a live URL:

python3 check_sitemap.py public/sitemap.xml
python3 check_sitemap.py https://www.example.com/sitemap.xml

Against a deliberately broken test file, the output looks like this:

urlset: 5 entries
ERROR   Entry 2: <loc> is not an absolute URL: /pricing
ERROR   Entry 4: <lastmod> is not a W3C Datetime: 20/10/2026
WARNING Entry 3: duplicate URL https://example.com/
WARNING Entry 3: <priority> must be 0.0 to 1.0, got 2
WARNING Entry 4: unexpected <changefreq> often
WARNING URLs span several hosts: example.com, www.example.com

And a file with an unescaped & fails at the parse step, before any entry checks:

ERROR   XML does not parse: not well-formed (invalid token): line 1, column 117

A few notes on the choices:

  • Errors are things that break the protocol or the parse. Warnings are things that are valid XML but probably wrong.
  • The host check is a warning, not an error, because cross-host sitemaps are allowed if you've proven ownership of the hosts (sitemaps.org describes how).
  • It doesn't handle gzipped sitemaps. If you serve .xml.gz, wrap load() with gzip.decompress().
  • For a sitemap index, it checks the index itself. To validate the child sitemaps, loop over the <loc> values and call check() on each.

Optional: check that listed URLs actually resolve

Protocol-valid isn't the same as useful. Here's a quick status-code pass that flags anything other than a 200:

import urllib.request
import xml.etree.ElementTree as ET

NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"

def status_report(sitemap_path, limit=200):
    root = ET.parse(sitemap_path).getroot()
    locs = [el.text.strip() for el in root.iter(NS + "loc")][:limit]
    for url in locs:
        req = urllib.request.Request(url, method="HEAD",
                                     headers={"User-Agent": "sitemap-check/1.0"})
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                final = resp.geturl()
                if final != url:
                    print("REDIRECT", url, "->", final)
        except urllib.error.HTTPError as e:
            print(e.code, url)
        except Exception as e:
            print("FAIL", url, e)

urlopen follows redirects, so the script compares the final URL with the listed one to catch them. Some servers reject HEAD requests; switch to GET if you see a lot of 405s. Keep the limit low and be polite to your own server.

Wiring it into CI

Generate the sitemap during the build, then validate it before deploying. A GitHub Actions step is enough:

- name: Validate sitemap
  run: python3 scripts/check_sitemap.py dist/sitemap.xml

Because the script exits with 1 on errors, a broken sitemap fails the build. Warnings print but don't block.

Two more habits that pay off:

  1. Diff the URL list on each release. A release that drops 3,000 URLs from the sitemap is worth a look before it ships.
  2. Reference the sitemap from robots.txt. Google says it will find a Sitemap: line in robots.txt the next time it crawls that file:
Sitemap: https://www.example.com/sitemap.xml

If you'd rather not run code

Appie.run has a set of free sitemap tools at appie.run/tools that cover the same ground in a browser. Each asks you to complete a Cloudflare Turnstile check before it runs:

  • XML Sitemap Validator: paste XML or a sitemap URL. It checks the root element, absolute URLs, lastmod, changefreq, priority, duplicates and the 50,000 URL limit, and gives a score that starts at 100 and drops for errors and warnings.
  • Sitemap Finder and Checker: enter a site URL. It reads Sitemap: lines in robots.txt, then tries /sitemap.xml, common sitemap index paths and the WordPress sitemap path, and reports which files load and how many URLs each lists.
  • Sitemap URLs Comparison: compare a "before" and "after" sitemap and get the added and removed URLs, which is the release diff above without writing code.
  • Sitemap Split and Merger: splits sets over 50,000 URLs into parts plus a sitemap index (you then replace the index locations with your real URLs).

They're handy for a one-off audit or for a client site where you can't add a CI step.

Related reading

FAQ

How do I check if my XML sitemap is valid?

Parse it as XML, confirm the root is urlset or sitemapindex in the sitemaps.org namespace, and check each <loc> is an absolute, escaped URL under 2,048 characters, with at most 50,000 URLs and 50MB uncompressed per file. The script above does this, or use a browser-based validator.

Does Google use priority and changefreq?

No. Google's documentation says it ignores both. It uses lastmod when it's consistently and verifiably accurate.

What is the maximum size of a sitemap?

50,000 URLs or 50MB uncompressed per file. For more, split into several sitemaps and list them in a sitemap index.

Why does my sitemap say "XML parse error"?

The most common cause is an unescaped & in a URL. Replace it with &amp;. Other causes include stray characters before the XML declaration and invalid UTF-8.

RG, Founder, Appie.run

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.