URL Mapping Implementation (Old → New) and 301-Redirect Configuration During Migration
301-redirects during migration are the primary tool for transferring SEO weight to pages on the new site. Every lost URL without a redirect is potentially lost traffic and search positions.
Creating a URL Mapping Table
URL-mapping is maintained in a CSV table that becomes a single source of truth:
old_url,new_url,status_code,priority,notes
/blog/2020/01/old-slug,/articles/old-slug,301,high,main page
/category/news,/blog/news,301,high,category page
/wp-content/uploads/img.jpg,/media/img.jpg,301,medium,media file
/contact-us,/contacts,301,high,page renamed
/product/old-name,/shop/new-name,301,high,product renamed
/old-promo-page,,410,low,deleted page
Status 410 (Gone) for deleted pages signals to search engines that the page is permanently deleted — better than 404 for pages that won't return.
Automatic URL Mapping by Slug
If only the URL structure changed (prefix added/removed):
def generate_url_map(old_urls, url_transform_fn):
mapping = []
for old_url in old_urls:
new_url = url_transform_fn(old_url)
if old_url != new_url:
mapping.append({'old': old_url, 'new': new_url, 'code': 301})
return mapping
# Examples of transformations
def wp_to_flat(url):
# /2020/01/post-slug → /articles/post-slug
import re
match = re.match(r'^/\d{4}/\d{2}/(.+)$', url)
if match:
return f"/articles/{match.group(1)}"
return url
def add_lang_prefix(url, lang='ru'):
# /page → /ru/page
if not url.startswith(f'/{lang}/'):
return f'/{lang}{url}'
return url
Generating nginx Map
def generate_nginx_map(mapping_csv, output_file):
import csv
lines = ['# Auto-generated redirects', 'map $request_uri $redirect_target {']
lines.append(' default "";')
lines.append(' hostnames;') # enable hostname patterns support
with open(mapping_csv) as f:
reader = csv.DictReader(f)
for row in reader:
old = row['old_url'].rstrip('/')
new = row['new_url']
code = row.get('status_code', '301')
if code == '410':
# Use different map for 410
continue
# Main URL
lines.append(f' "~^{re.escape(old)}$" "{new}";')
# With trailing slash
if old != '/':
lines.append(f' "~^{re.escape(old)}/$" "{new}";')
lines.append('}')
with open(output_file, 'w') as f:
f.write('\n'.join(lines))
Nginx configuration:
include /etc/nginx/redirect_map.conf;
server {
listen 80;
server_name site.com www.site.com;
# Process redirects
if ($redirect_target != "") {
return 301 $redirect_target;
}
# 410 for deleted pages
location ~* ^/(old-promo|deleted-category|removed-product) {
return 410;
}
# Universal redirect for unknown old paths
# (caution — may break new content)
# try_files $uri $uri/ @legacy_redirect;
}
Generating .htaccess for Apache
def generate_htaccess(mapping_csv, output_file):
lines = [
'RewriteEngine On',
'RewriteBase /',
''
]
with open(mapping_csv) as f:
reader = csv.DictReader(f)
for row in reader:
old = row['old_url'].lstrip('/')
new = row['new_url']
code = row.get('status_code', '301')
if code == '410':
lines.append(f'RewriteRule ^{re.escape(old)}$ - [G,L]')
else:
lines.append(f'RewriteRule ^{re.escape(old)}$ {new} [R={code},L]')
with open(output_file, 'w') as f:
f.write('\n'.join(lines))
Redirect Coverage Verification
import requests
def verify_redirects(mapping_csv, base_url):
errors = []
with open(mapping_csv) as f:
reader = csv.DictReader(f)
for row in reader:
old_url = f"{base_url}{row['old_url']}"
expected_new = row['new_url']
expected_code = int(row.get('status_code', 301))
# Check redirect only, don't follow
resp = requests.get(old_url, allow_redirects=False)
if expected_code in (301, 302):
if resp.status_code != expected_code:
errors.append(f"Expected {expected_code}, got {resp.status_code}: {old_url}")
elif not resp.headers.get('Location', '').endswith(expected_new):
errors.append(f"Wrong redirect target: {old_url} → {resp.headers.get('Location')}, expected {expected_new}")
elif expected_code == 410:
if resp.status_code != 410:
errors.append(f"Expected 410, got {resp.status_code}: {old_url}")
return errors
Crawling Old Site for Complete Coverage
Before setting up redirects, a complete URL list is needed:
# Screaming Frog export all URLs
# or wget crawling
wget --spider --recursive --no-verbose --output-file=crawl.log \
https://old-site.com 2>&1
grep -E "^--" crawl.log | awk '{print $3}' | sort -u > all_urls.txt
# Find URLs from all_urls.txt not covered by redirects
with open('all_urls.txt') as f:
crawled_urls = {line.strip() for line in f}
with open('mapping.csv') as f:
mapped_old_urls = {row['old_url'] for row in csv.DictReader(f)}
uncovered = crawled_urls - mapped_old_urls
print(f"Uncovered URLs ({len(uncovered)}):")
for url in sorted(uncovered):
print(f" {url}")
GSC Monitoring After Launch
Google Search Console → Coverage → Excluded → Crawled – currently not indexed
If many new 404s appear after migration, these are missed redirects. Pages with traffic (from GSC Performance) should be covered by redirects in priority order.
Timeline
Creating mapping for a site with up to 1000 URLs, generating nginx config and verification — 2–3 working days.







