SEO migrations: verify old URLs and their destinations

Run a local HTTP migration map, reject broken redirects and canonical markup, and keep response evidence separate from relevance and search outcomes.
Keep response checks separate from page usefulness
A successful response does not mean a migration preserved the page a visitor needed. In a fictional example, a detailed maintenance offer is replaced by a broad consulting homepage. Both return 200, but the new page no longer describes maintenance coverage or how to request it. I recommend reviewing the destination’s job before approving its URL mapping.
Build the inventory from the CMS and important linked or visited URLs, rather than treating the new navigation as a complete list. Record which offer or answer each page supplies and who can approve its replacement. Google’s migration guidance recommends using sources such as sitemaps, logs and incoming links when finding old URLs. This is preparation for a decision, not evidence that the new content is equivalent.
The example below executes a small HTTP contract against synthetic pages. It does not move a live site or measure search performance. The fixture bodies deliberately contain almost no content: their response checks can pass while they remain useless to a real buyer. No ranking, traffic or enquiry improvement is claimed.
Make the mapping express a decision
The embedded CSV keeps /services/site-care at its existing path, moves /services/old-audit directly to /services/technical-audit, and removes /offers/retired-package with 410. Those are invented paths. For this exercise, assume the audit page remains equivalent and the retired offer has no replacement. The CSV is consumed by the checker, not by a production web server.
I recommend retaining the site-care path when its purpose and replacement content are unchanged. A new visual design alone is not a reason to rename it. I would reject sending the retired offer to an unrelated homepage: that hides the removal decision without supplying the old offer. Google warns that irrelevant redirects may be treated as soft 404s and documents 404 or 410 for content without a replacement.
For a real move, Google recommends permanent server redirects where possible and direct destinations instead of chains. Its guidance also calls for new URLs in canonical annotations, internal links and sitemaps. The fixture chooses 301 and an HTML canonical for the intended public URL. It does not establish that Google will select that canonical or index the page.
Execute the first response and destination checks
This is the complete module executed under pinned Python 3.12.12. Save it as migration.py and run it with that Python version to inspect the normal JSON result. The source bundle adds the Docker runner, immutable runtime digest and negative controls. The server binds a random container-loopback port; no example-domain URL is fetched.
The request opener disables automatic redirect following. For each CSV row, the checker inspects the first status and Location header. A redirect must name the exact final path before that path is requested separately. The destination must return 200 without another Location, and the parsed HTML must contain exactly one matching canonical. A keep row is checked directly; a removed row must return its declared status without a redirect.
The recorded normal run passed 12 assertions over five HTTP responses. Keep returned 200 with its matching canonical. The old audit path returned 301 to the audit destination, whose response was 200 with its own canonical. The retired offer returned 410, and an unknown path returned 404. These are observed local responses, not responses from an Ulties deployment.
"""Synthetic HTTP migration witness. No live website is requested or changed."""
import csv
import hashlib
from html.parser import HTMLParser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import io
import json
from pathlib import Path
import sys
import threading
import urllib.error
import urllib.request
MAP = """old_path,decision,new_path,status,canonical
/services/site-care,keep,/services/site-care,200,/services/site-care
/services/old-audit,redirect,/services/technical-audit,301,/services/technical-audit
/offers/retired-package,remove,,410,
"""
ORIGIN = 'https://new.example'
def page(path):
return '<link rel="canonical" href="' + ORIGIN + path + '"><h1>Fixture</h1>'
ROUTES = {
'/services/site-care': (200, None, page('/services/site-care')),
'/services/old-audit': (301, '/services/technical-audit', ''),
'/services/technical-audit': (200, None, page('/services/technical-audit')),
'/offers/retired-package': (410, None, 'gone'),
'/intermediate': (301, '/services/technical-audit', ''),
'/': (200, None, page('/')),
}
class Canonicals(HTMLParser):
def __init__(self):
super().__init__()
self.values = []
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'link' and 'canonical' in attrs.get('rel', '').lower().split():
self.values.append(attrs.get('href'))
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
status, location, body = ROUTES.get(self.path, (404, None, 'not found'))
data = body.encode()
self.send_response(status)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(data)))
if location is not None:
self.send_header('Location', location)
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args):
return None
passed, trace = [], []
def check(condition, marker):
if not condition:
raise AssertionError(marker)
passed.append(marker)
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base = 'http://127.0.0.1:' + str(server.server_port)
opener = urllib.request.build_opener(NoRedirect())
def get(path):
try:
response = opener.open(base + path, timeout=3)
except urllib.error.HTTPError as error:
response = error
with response:
value = dict(path=path, status=response.status,
location=response.headers.get('Location'), body=response.read().decode())
trace.append(value)
return value
try:
for row in csv.DictReader(io.StringIO(MAP)):
label = row['decision']
first = get(row['old_path'])
check(first['status'] == int(row['status']), label + ':first-status')
if label == 'redirect':
check(first['location'] == row['new_path'], 'redirect:direct-location')
final = get(row['new_path'])
check(final['status'] == 200, 'redirect:final-status')
check(final['location'] is None, 'redirect:final-no-location')
else:
check(first['location'] is None, label + ':no-location')
final = first
if row['canonical']:
parser = Canonicals()
parser.feed(final['body'])
check(parser.values == [ORIGIN + row['canonical']], label + ':canonical')
unknown = get('/unknown')
check(unknown['status'] == 404, 'unknown:status')
check(unknown['location'] is None, 'unknown:no-location')
result = dict(status='pass', passed=passed, trace=trace)
except AssertionError as error:
result = dict(status='fail', assertion=str(error), passed=passed, trace=trace)
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
result.update(python=sys.version.split()[0],
sourceSha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest())
print(json.dumps(result, sort_keys=True))
raise SystemExit(0 if result['status'] == 'pass' else 1)
Make the checker reject plausible mistakes
Seven actual mutations changed the fixture’s response or markup source. A wrong Location, a hop through /intermediate and a self-loop each failed redirect:direct-location. They are rejected at the first header by the direct-hop rule; the checker does not follow them or claim to analyze arbitrary redirect graphs.
A wrong final canonical and duplicate final canonical tags each failed redirect:canonical. A destination changed to 500 failed redirect:final-status. Sending the retired offer to the homepage failed remove:first-status. Each control exited unsuccessfully with its intended assertion, the exact preceding normal assertions and the hash of the changed module that ran.
Those failures show that this checker detects these specific regressions. They do not make its coverage complete. The harness checks static HTML canonical markup, not HTTP Link headers, JavaScript rendering, robots directives or language annotations. It supplies no policy for query parameters, alternate hostnames or trailing-slash variants. Add representative cases from the actual site rather than assuming these three paths cover it.
Review what a response trace cannot settle
For the fictional audit move, the content owner still needs to compare the old audit scope with the destination. If the replacement only offers general consulting, I recommend refusing the mapping until the audit answer is retained or the offer removal is explicitly approved. Correct status and canonical values would not repair that mismatch.
In the real implementation, follow old campaign and proposal links through the public hostname. Inspect required downloads and the enquiry route. Use clearly marked synthetic submissions and verify application acceptance separately from receipt by the intended team. This HTTP fixture has neither a form nor a receiving system, so it cannot provide that release evidence.
Before release, review production crawl controls and remove staging-only noindex settings where appropriate. Update the routing configuration together with its application release record. Rehearse recovery against that pair: an old application combined with a new redirect map can behave differently from either reviewed release. The fixture does not test that recovery.
Keep ownership after cutover
Google recommends keeping redirects as long as possible, generally at least one year, and warns that visibility can fluctuate during a move. Assign an owner to retain the old routing service and its certificates when needed. A passing local fixture is not a reason to remove them early or declare the migration successful.
Search Console provides indexing and page/query performance information, including impressions and clicks. Keep those observations separate from application errors and enquiry records. A drop in search clicks, a rejected form and a missing CRM receipt are different findings. Check measurement changes before attributing any of them to the redesign.
I recommend closing the implementation review only when the real response map and destination content have named approval, with remaining risks recorded. Continue monitoring the public system after cutover. This example provides a reproducible HTTP boundary and deliberate failures, not a search forecast or proof that a business outcome has been preserved.
Reproduce the examples
Exact module, isolated runner, immutable Python runtime and observed normal/negative results. Requires Docker and Python; no live site is requested.
Download the source and tests (.zip)
