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

Where does this URL really end up? Trace redirect chains hop by hop in Python

Every redirect is one more request before a crawler or a visitor gets content. A URL moved in 2021, moved again at an HTTPS migration and caught by a trailing-slash rule later can end up three or four hops from its final

Every redirect is one more request before a crawler or a visitor gets content. A URL moved in 2021, moved again at an HTTPS migration and caught by a trailing-slash rule later can end up three or four hops from its final address, and nobody notices because the browser hides all of it.

This post is a short, dependency-free Python script that follows one URL hop by hop, records every status code and Location header, and flags the patterns that matter for SEO: chains, loops, temporary redirects, https to http downgrades and chains that end in an error. It is part of my series on small Python checks for technical SEO. It pairs with the canonical signals audit, which compares the final URL with the canonical tag, and with the sitemap lastmod audit, because sitemap URLs should be final URLs.

What Google documents about redirects

Three statements from Google's documentation drive the checks. I looked them up on 12 November 2026:

  • By default, Google's crawlers follow up to 10 redirect hops, and specific products' crawlers may have different limits.
  • 301 and 308 are a strong signal that the redirect target should be processed. 302 and 307 are a weak signal, and 303 is listed with 302.
  • Permanent redirects show the new target in search results and temporary redirects show the source page. Server-side redirects come first, meta refresh is a fallback, and JavaScript redirects are a last resort because Google might never see them if rendering fails.

I could not find any guidance on redirect chains or loops on either page. So "a chain is a problem" is my own threshold, not a Google rule: I flag two or more hops because each hop is an extra request and one more rule that can break. The 10-hop limit is the only documented hard number, and the script uses it as the default.

What the script checks

It sends a GET request without following redirects, reads the status code and the Location header, resolves the location against the current URL (relative locations work), drops fragments and requests the next URL. It stops on a final response, a loop, a missing Location header, a network error, or after more redirects than --max-hops allows.

What it reports:

  • Chains: two or more hops before the final response.
  • Loops: a redirect that points back to a URL already requested.
  • Temporary redirects: 302, 303 or 307 anywhere in the chain. These are often intentional (maintenance, language switching), so this is a warning to check intent.
  • https to http downgrades.
  • Dead ends: the chain ends in a 4xx or 5xx response.
  • Broken redirects: a 3xx without a Location header, or a request that fails.

The exit code is 1 when any URL has an error. Add --strict to fail on warnings as well, so the script can run in CI after a deploy or in a scheduled check.

The script

Save it as redirect_trace.py. It uses only the standard library.

#!/usr/bin/env python3
"""Trace redirect chains hop by hop, without following them automatically.

Usage:
    python redirect_trace.py https://example.com/old
    python redirect_trace.py --file urls.txt --csv report.csv
    python redirect_trace.py --max-hops 10 --strict URL [URL ...]
"""
import argparse
import csv
import http.client
import sys
import urllib.parse

PERMANENT = {301, 308}
TEMPORARY = {302, 303, 307}
REDIRECTS = PERMANENT | TEMPORARY
USER_AGENT = "redirect-trace/1.0"


def fetch_once(url, timeout=15):
    """Send one GET request and return (status, Location header). Never follows."""
    parts = urllib.parse.urlsplit(url)
    if parts.scheme not in ("http", "https") or not parts.hostname:
        raise ValueError(f"not an absolute http(s) URL: {url}")
    https = parts.scheme == "https"
    path = urllib.parse.quote(parts.path or "/", safe="/%:@!$&'()*+,;=-._~")
    if parts.query:
        path += "?" + urllib.parse.quote(parts.query, safe="%:@!$&'()*+,;=/?-._~")
    conn_class = http.client.HTTPSConnection if https else http.client.HTTPConnection
    conn = conn_class(parts.hostname, parts.port or (443 if https else 80), timeout=timeout)
    try:
        conn.request("GET", path, headers={"User-Agent": USER_AGENT, "Accept": "*/*"})
        response = conn.getresponse()
        return response.status, response.getheader("Location")
    finally:
        conn.close()


def resolve(base, location):
    """Turn a Location header into an absolute URL without its fragment."""
    return urllib.parse.urldefrag(urllib.parse.urljoin(base, location.strip()))[0]


def trace(url, fetch=fetch_once, max_hops=10):
    """Request url, then each Location in turn. Returns a dict describing the chain."""
    current = urllib.parse.urldefrag(url.strip())[0]
    seen = {current}
    result = {"start": current, "hops": [], "final_url": None,
              "final_status": None, "stopped": None}
    while True:
        try:
            status, location = fetch(current)
        except (OSError, http.client.HTTPException, ValueError) as exc:
            result["stopped"] = f"request failed at {current}: {exc}"
            return result
        if status not in REDIRECTS:
            result["final_url"], result["final_status"] = current, status
            return result
        target = resolve(current, location) if location else None
        result["hops"].append({"url": current, "status": status, "to": target})
        if target is None:
            result["stopped"] = f"HTTP {status} without a Location header at {current}"
            return result
        if target in seen:
            result["stopped"] = f"redirect loop back to {target}"
            return result
        if len(result["hops"]) > max_hops:
            result["stopped"] = f"more than {max_hops} hops, not followed any further"
            return result
        seen.add(target)
        current = target


def analyze(result):
    """Return (level, message) pairs for one traced chain."""
    problems = []
    hops = result["hops"]
    if result["stopped"]:
        problems.append(("error", result["stopped"]))
    status = result["final_status"]
    if status is not None and status >= 400:
        problems.append(("error", f"chain ends at HTTP {status}"))
    if len(hops) >= 2 and status is not None:
        problems.append(("warn", f"chain of {len(hops)} hops: link straight to the final URL"))
    temporary = sorted({h["status"] for h in hops if h["status"] in TEMPORARY})
    if temporary:
        codes = ", ".join(str(code) for code in temporary)
        problems.append(("warn", f"temporary redirect ({codes}): the source URL stays in "
                                 "search results; use 301 or 308 for a permanent move"))
    for hop in hops:
        if hop["url"].startswith("https://") and (hop["to"] or "").startswith("http://"):
            problems.append(("warn", f"https to http downgrade at {hop['url']}"))
    return problems


def describe(result):
    codes = [str(hop["status"]) for hop in result["hops"]]
    if result["final_status"] is not None:
        codes.append(str(result["final_status"]))
    return " > ".join(codes) or "-"


def print_report(result, problems):
    print(result["start"])
    for hop in result["hops"]:
        print(f"  {hop['status']} -> {hop['to'] or '(no Location)'}")
    if result["final_status"] is not None:
        print(f"  {result['final_status']} {result['final_url']}")
    for level, message in problems:
        print(f"  {level.upper()}: {message}")


def csv_row(result, problems):
    return [result["start"], len(result["hops"]), describe(result),
            result["final_url"] or "", result["final_status"] or "",
            "; ".join(f"{level}: {message}" for level, message in problems)]


def read_urls(args):
    urls = list(args.urls)
    if args.file:
        with open(args.file, encoding="utf-8") as handle:
            urls += [line.strip() for line in handle
                     if line.strip() and not line.startswith("#")]
    return [u if "://" in u else "https://" + u for u in urls]


def main(argv=None):
    parser = argparse.ArgumentParser(description="Trace redirect chains hop by hop.")
    parser.add_argument("urls", nargs="*")
    parser.add_argument("--file", help="text file with one URL per line")
    parser.add_argument("--max-hops", type=int, default=10)
    parser.add_argument("--timeout", type=float, default=15)
    parser.add_argument("--csv", help="write one row per URL to this CSV file")
    parser.add_argument("--strict", action="store_true", help="exit 1 on warnings too")
    args = parser.parse_args(argv)
    urls = read_urls(args)
    if not urls:
        parser.error("give at least one URL or --file")

    rows, with_errors, with_warnings = [], 0, 0
    for url in urls:
        result = trace(url, fetch=lambda u: fetch_once(u, args.timeout),
                       max_hops=args.max_hops)
        problems = analyze(result)
        print_report(result, problems)
        rows.append(csv_row(result, problems))
        levels = {level for level, _ in problems}
        with_errors += "error" in levels
        with_warnings += levels == {"warn"}
    clean = len(urls) - with_errors - with_warnings
    print(f"\n{len(urls)} URLs: {clean} clean, {with_warnings} with warnings, "
          f"{with_errors} with errors")

    if args.csv:
        with open(args.csv, "w", newline="", encoding="utf-8") as handle:
            writer = csv.writer(handle)
            writer.writerow(["start_url", "hops", "status_chain", "final_url",
                             "final_status", "issues"])
            writer.writerows(rows)
    return 1 if with_errors or (args.strict and with_warnings) else 0


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

Run it on test URLs

I ran it against a small local server that I wrote for the tests, with invented paths: a single 301, a chain of three 301s, a 302, a loop, and a 301 to a missing page.

python redirect_trace.py http://127.0.0.1:8099/one http://127.0.0.1:8099/chain http://127.0.0.1:8099/temp http://127.0.0.1:8099/loop-a http://127.0.0.1:8099/dead

http://127.0.0.1:8099/one
  301 -> http://127.0.0.1:8099/ok
  200 http://127.0.0.1:8099/ok
http://127.0.0.1:8099/chain
  301 -> http://127.0.0.1:8099/chain-b
  301 -> http://127.0.0.1:8099/chain-c
  301 -> http://127.0.0.1:8099/ok
  200 http://127.0.0.1:8099/ok
  WARN: chain of 3 hops: link straight to the final URL
http://127.0.0.1:8099/temp
  302 -> http://127.0.0.1:8099/ok
  200 http://127.0.0.1:8099/ok
  WARN: temporary redirect (302): the source URL stays in search results; use 301 or 308 for a permanent move
http://127.0.0.1:8099/loop-a
  301 -> http://127.0.0.1:8099/loop-b
  301 -> http://127.0.0.1:8099/loop-a
  ERROR: redirect loop back to http://127.0.0.1:8099/loop-a
http://127.0.0.1:8099/dead
  301 -> http://127.0.0.1:8099/missing
  404 http://127.0.0.1:8099/missing
  ERROR: chain ends at HTTP 404

5 URLs: 1 clean, 2 with warnings, 2 with errors

That output comes from my own test server, not from a live site. The exit code was 1 because two URLs had errors. Add --csv report.csv to get one row per URL with the status chain, final URL and issues for a spreadsheet.

Reading the results

  • One permanent redirect is fine. The first URL above printed no warning, and there is nothing to fix.
  • A chain: update the first URL, your internal links, the sitemap entries and the canonical tags to point straight at the final URL. Keep the old redirects in place so external links still work.
  • A temporary redirect: if the move is permanent, switch to 301 or 308. If it is intentional, such as a short maintenance redirect, leave it, and remember that Google's documentation says the source page is the one shown in search results.
  • A loop or a dead end is always a bug. A loop never reaches content, and a chain that ends in a 404 sends the visitor and the crawler to an error page.
  • https to http usually points to a CDN, plugin or server rule that was written before the site moved to HTTPS.

What I tested, and what I did not

I ran 19 tests against a real HTTP server on localhost, written with http.server. They cover: no redirect, a single 301, a chain of three, 302 and 307, a loop, a 301 to a 404, relative, absolute and fragment locations, a query string kept through a redirect, a redirect with no Location header, a refused connection, a non-ASCII path, the hop limit at exactly 10 and at 11, the CSV rows and the exit codes. The https to http check runs on a hand-made hop record, because the test server speaks plain HTTP. I also broke the code three ways (an off-by-one in the hop limit, no percent-encoding of the path, no loop check) and confirmed that a test failed each time.

I did not test HTTPS and real certificates, any live site, CDNs or firewalls that treat an unfamiliar user agent differently, servers that answer GET and HEAD differently, or Location headers that contain raw non-ASCII bytes.

Limitations

  • It only sees HTTP redirects. A page that returns 200 and then redirects with meta refresh or JavaScript looks clean here.
  • It sends a generic user agent. Some servers redirect by user agent, country or cookie, so the chain you see may not be the chain Googlebot gets. Your access logs tell you what the real crawler received, and the first post in this series shows how to verify real Googlebot hits.
  • It checks one URL at a time. For thousands of URLs, add a thread pool and a delay between requests to the same host.
  • The 10-hop limit comes from Google's documentation and is described there as the default, so treat it as a guide and not as a guarantee.

Sources

I'm Hanin, an SEO specialist working on technical SEO, GEO and entity building. I write small, checkable scripts like this one for the series "Python checks for technical SEO". If you run it on a messy site, tell me what it caught.

📰 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.