How to Compare Two XML Sitemaps Before and After a Site Migration With Python (Find Removed URLs and Missing Redirects)

A site migration (new domain, new CMS, new URL structure) is where search traffic quietly goes missing. Pages that existed before the move disappear from the new site, and nobody notices until rankings drop. Your old and new XML sitemaps are the quickest record of what changed. This post walks through a small Python script that diffs them, follows sitemap indexes, handles a domain change, and checks every removed URL against your redirect map. Disclosure: Appie.run publishes this post. Appie has free sitemap tools, mentioned near the end. The script below has no dependency on them, and it uses only the Python standard library. Earlier posts in this series cover generating a sitemap for a static site and validating a sitemap. Run the validator on both files first if you are not sure they are well formed. What the script answers Which URLs are in the old sitemap but not the new one (removed)? Which are new (added)? Which survived (kept)? For every removed URL, is there a r

How to Compare Two XML Sitemaps Before and After a Site Migration With Python (Find Removed URLs and Missing Redirects)

A site migration (new domain, new CMS, new URL structure) is where search traffic quietly goes missing. Pages that existed before the move disappear from the new site, and nobody notices until rankings drop. Your old and new XML sitemaps are the quickest record of what changed. This post walks through a small Python script that diffs them, follows sitemap indexes, handles a domain change, and checks every removed URL against your redirect map.

Disclosure: Appie.run publishes this post. Appie has free sitemap tools, mentioned near the end. The script below has no dependency on them, and it uses only the Python standard library.

Earlier posts in this series cover generating a sitemap for a static site and validating a sitemap. Run the validator on both files first if you are not sure they are well formed.

What the script answers

  1. Which URLs are in the old sitemap but not the new one (removed)?
  2. Which are new (added)?
  3. Which survived (kept)?
  4. For every removed URL, is there a redirect in your map, and does the target actually exist in the new sitemap?

Question 4 is the one that saves you. A redirect pointing at a page that was never built is as bad as no redirect.

Before you run it

  • Save the old sitemap before launch day. Once the new site is live, the old sitemap.xml is usually gone. Download it (and every child sitemap) while you still can.
  • Export your redirect map as a two-column CSV: old_url,new_url. Most migration plans already have one in a spreadsheet.

The script

Save this as sitemap_diff.py:

#!/usr/bin/env python3
"""Compare two XML sitemaps (before and after a site migration)."""
import argparse
import csv
import gzip
import sys
import urllib.request
import xml.etree.ElementTree as ET
from urllib.parse import urlsplit, urlunsplit

NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
UA = "sitemap-diff/1.0 (+https://example.com/contact)"


def read_source(src):
    """Return raw bytes from a URL or a local file path."""
    if src.startswith(("http://", "https://")):
        req = urllib.request.Request(src, headers={"User-Agent": UA})
        with urllib.request.urlopen(req, timeout=30) as resp:
            data = resp.read()
    else:
        with open(src, "rb") as fh:
            data = fh.read()
    if data[:2] == b"\x1f\x8b":  # gzip magic number
        data = gzip.decompress(data)
    return data


def collect_urls(src, seen=None, depth=0):
    """Return the set of page URLs in a sitemap, following sitemap indexes."""
    seen = seen if seen is not None else set()
    if src in seen or depth > 3:
        return set()
    seen.add(src)
    root = ET.fromstring(read_source(src))
    tag = root.tag.replace(NS, "")
    locs = [el.text.strip() for el in root.iter(NS + "loc") if el.text]
    if tag == "sitemapindex":
        urls = set()
        for child in locs:
            urls |= collect_urls(child, seen, depth + 1)
        return urls
    if tag == "urlset":
        return set(locs)
    raise ValueError(f"{src}: root element is <{tag}>, expected urlset or sitemapindex")


def normalise(url, old_host=None, new_host=None, ignore_slash=True):
    """Make URLs comparable: lowercase host, optional host swap, trailing slash."""
    parts = urlsplit(url)
    host = parts.netloc.lower()
    if old_host and host == old_host.lower():
        host = new_host.lower()
    path = parts.path or "/"
    if ignore_slash and len(path) > 1:
        path = path.rstrip("/")
    return urlunsplit((parts.scheme.lower(), host, path, parts.query, ""))


def load_redirects(path):
    """Read a two-column CSV of old_url,new_url."""
    with open(path, newline="", encoding="utf-8") as fh:
        return {row[0].strip(): row[1].strip() for row in csv.reader(fh) if len(row) >= 2}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("before", help="old sitemap: URL or file")
    ap.add_argument("after", help="new sitemap: URL or file")
    ap.add_argument("--old-host", help="host used before the move, e.g. www.old.com")
    ap.add_argument("--new-host", help="host used after the move, e.g. www.new.com")
    ap.add_argument("--redirects", help="CSV of old_url,new_url to check removed URLs against")
    ap.add_argument("--out", default="sitemap-diff.csv", help="where to write the report")
    args = ap.parse_args()

    if bool(args.old_host) != bool(args.new_host):
        ap.error("--old-host and --new-host go together")

    before_raw = collect_urls(args.before)
    after_raw = collect_urls(args.after)

    before = {normalise(u, args.old_host, args.new_host): u for u in before_raw}
    after = {normalise(u): u for u in after_raw}

    kept = before.keys() & after.keys()
    removed = before.keys() - after.keys()
    added = after.keys() - before.keys()

    redirects = load_redirects(args.redirects) if args.redirects else {}
    after_norm = set(after.keys())

    rows = []
    for key in sorted(removed):
        old = before[key]
        target = redirects.get(old, "")
        if not redirects:
            note = ""
        elif not target:
            note = "no redirect mapped"
        elif normalise(target) not in after_norm:
            note = "redirect target not in new sitemap"
        else:
            note = "ok"
        rows.append(("removed", old, target, note))
    rows += [("added", after[k], "", "") for k in sorted(added)]
    rows += [("kept", after[k], "", "") for k in sorted(kept)]

    with open(args.out, "w", newline="", encoding="utf-8") as fh:
        writer = csv.writer(fh)
        writer.writerow(["status", "url", "redirect_to", "note"])
        writer.writerows(rows)

    print(f"before: {len(before)}  after: {len(after)}")
    print(f"kept: {len(kept)}  added: {len(added)}  removed: {len(removed)}")
    if redirects:
        problems = sum(1 for r in rows if r[0] == "removed" and r[3] != "ok")
        print(f"removed URLs without a valid redirect: {problems}")
    print(f"report: {args.out}")
    return 1 if redirects and problems else 0


if __name__ == "__main__":
    sys.exit(main())

What each part does

  • read_source accepts a URL or a local file and decompresses .gz sitemaps by checking the gzip magic bytes rather than trusting the file name.
  • collect_urls reads the root element. A returns its values. A makes the function fetch each child sitemap and merge the results. The seen set and depth limit stop a loop if an index lists itself.
  • normalise makes URLs comparable. It lowercases the scheme and host, drops fragments, treats /page and /page/ as the same, and, if you pass --old-host and --new-host, rewrites the old host so https://www.old-shop.com/pricing/ matches https://www.new-shop.com/pricing.
  • The diff is three set operations on the normalised keys. The original URLs are kept for the report, so you see exactly what each sitemap listed.
  • Redirect checks run only when you pass --redirects. Each removed URL is marked ok, no redirect mapped, or redirect target not in new sitemap.
  • Exit code is 1 when any removed URL lacks a valid redirect, so you can fail a CI job on it.

Run it

A small example with an old site at www.old-shop.com and a new one at www.new-shop.com, where the new sitemap is an index pointing to a child file:

python3 sitemap_diff.py old-sitemap.xml https://www.new-shop.com/sitemap_index.xml \
  --old-host www.old-shop.com --new-host www.new-shop.com \
  --redirects redirects.csv

Output:

before: 4  after: 5
kept: 2  added: 3  removed: 2
removed URLs without a valid redirect: 1
report: sitemap-diff.csv

And sitemap-diff.csv:

status,url,redirect_to,note
removed,https://www.old-shop.com/about-us,https://www.new-shop.com/about,redirect target not in new sitemap
removed,https://www.old-shop.com/blog/launch-post,https://www.new-shop.com/blog/launch,ok
added,https://www.new-shop.com/blog/launch,,
added,https://www.new-shop.com/company,,
added,https://www.new-shop.com/integrations,,
kept,https://www.new-shop.com/,,
kept,https://www.new-shop.com/pricing,,

The first row is the kind of bug this catches: the redirect map sends /about-us to /about, but the new site calls that page /company. Fix the map before launch, not after.

Notice that /pricing/ and /pricing count as kept because of the trailing slash rule. If your new site really treats them as different pages, run without that rule (set ignore_slash=False in normalise).

Optional: check what the live URLs actually do

The map says what should happen. After launch, check what does happen. This second script reads the report and requests each removed URL without following redirects, so you see the first hop:

#!/usr/bin/env python3
"""For each removed URL in sitemap-diff.csv, request it and report where it lands."""
import csv
import sys
import urllib.error
import urllib.request


class NoFollow(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *args, **kwargs):
        return None  # stop at the first hop so we can see the status code


opener = urllib.request.build_opener(NoFollow)


def first_hop(url, method="HEAD"):
    req = urllib.request.Request(url, method=method, headers={"User-Agent": "sitemap-diff/1.0"})
    try:
        with opener.open(req, timeout=20) as resp:
            return resp.status, ""
    except urllib.error.HTTPError as err:  # 3xx lands here because we refuse to follow
        if method == "HEAD" and err.code in (405, 500, 501):
            return first_hop(url, "GET")  # some servers reject HEAD
        return err.code, err.headers.get("Location", "")
    except urllib.error.URLError as err:
        return 0, str(err.reason)


report = sys.argv[1] if len(sys.argv) > 1 else "sitemap-diff.csv"
with open(report, newline="", encoding="utf-8") as fh:
    for row in csv.DictReader(fh):
        if row["status"] != "removed":
            continue
        code, location = first_hop(row["url"])
        flag = "" if code in (301, 308) else "  <-- check"
        print(f"{code} {row['url']} -> {location}{flag}")

Example output (illustrative URLs):

301 https://www.old-shop.com/blog/launch-post -> https://www.new-shop.com/blog/launch
404 https://www.old-shop.com/about-us ->   <-- check

Anything that is not a 301 or 308 gets flagged. A 302 is a temporary redirect, which is usually not what you want for a permanent move, and a 200 means the old page still serves content. Some servers reject HEAD requests, so the script retries those with GET. Keep the request rate polite on large sites; for tens of thousands of URLs, add a short time.sleep between requests or run it on a sample.

Wire it into a pre-launch check

# fails the job if any removed URL lacks a valid redirect
python3 sitemap_diff.py old-sitemap.xml staging-sitemap.xml \
  --old-host www.old-shop.com --new-host staging.new-shop.com \
  --redirects redirects.csv --out diff.csv

Running it against the staging sitemap on every deploy means a page dropped by a template change shows up the same day.

Known limits

  • A sitemap is not the whole site. Pages that were never in the old sitemap won't be checked. Combine this with a crawl or your analytics' top landing pages.
  • Query strings are compared as-is. If the old site used ?id=12 and the new one uses clean paths, rely on the redirect map rather than the kept count.
  • Relative values inside an index are read as local paths. The sitemap protocol expects absolute URLs, so this only matters for hand-made test files.
  • Memory. Everything is held in sets. That is fine for a few hundred thousand URLs; beyond that, stream to disk.

When a script is more than you need

For a one-off check, Appie's free tools do the core steps in the browser:

  • Sitemap URLs Comparison Tool takes two sitemaps, by URL or pasted XML, and reports how many URLs stayed, which were added and which were removed. The first sitemap is treated as the before state.
  • Sitemap URL Extractor lists one URL per line from a sitemap. Given a sitemap index, it lists the child sitemaps, so you run it again on each child.
  • Sitemap Finder and Checker finds sitemaps through robots.txt and the usual paths, which helps when you don't know where the old site kept them.

Each asks for a captcha before running. They don't check redirect maps or host changes, which is where the script above earns its keep.

RG, Founder, Appie.run

Share