Migrating WordPress Permalinks Without Losing Rankings
Problem Statement
You need to change a WordPress site’s permalink structure — for example from /?p=123 or /2019/03/post-name/ to /category/post-name/ — without dropping rankings or stranding inbound links. WordPress will silently 404 every old URL the moment you save the new structure unless you generate a deterministic 301 map and serve it from a layer that fires before WordPress renders. This page is part of the broader CMS & Framework Routing Changes section.
When to Use This Approach
- You are changing the permalink structure on a live, indexed WordPress site.
- Old URLs have backlinks, bookmarks, or organic traffic you cannot afford to lose.
- You want the old-to-new mapping in version control, not buried in plugin tables.
- You are deciding between the Redirection plugin (application layer) and server rules (
.htaccess/Nginx). - You are moving WordPress behind a new domain or onto a different host at the same time.
Step-by-Step Instructions
1. Export the Current URL List Before Changing Anything
Capture every published URL under the old structure so you can map it deterministically. WP-CLI gives you a clean export without touching the database.
# Export post ID, status, and current permalink for all published posts and pages
wp post list --post_type=post,page --post_status=publish \
--fields=ID,post_title,url --format=csv > old-urls.csv
Export from the database rather than by crawling, and export more than the URL. Take the post ID, the current permalink, the post type and the publication date — the ID is what lets you reconstruct the new URL deterministically once the structure changes, and the date is what most permalink structures are built from. A crawl-based export gives you the URLs and not the identifiers, which means the mapping afterwards has to be inferred from slugs rather than computed, and inference fails wherever two posts share a slug across different dates.
Verify the export is complete before proceeding, by comparing its row count against the published post count for each post type. This is the one irreversible step on the page: once the structure changes, the previous permalinks are no longer derivable for posts whose slug or date has since been edited.
2. Change the Permalink Structure and Flush Rules
Set the new structure once, then flush rewrite rules so WordPress writes the new internal routing. The --hard flag updates .htaccess on Apache.
# Apply new permalink structure and regenerate rewrite rules
wp rewrite structure '/%category%/%postname%/' --hard
wp rewrite flush --hard
3. Generate the 301 Map
Build an explicit old→new mapping. Because post IDs are stable, you can resolve each old URL to its new permalink and emit redirect lines.
# Emit "old_path new_path" pairs for each published post (IDs are stable across the change)
wp post list --post_type=post --post_status=publish --field=ID | \
while read id; do
printf '%s %s\n' "/?p=$id" "$(wp post url $id)"
done > redirect-map.txt
Generate the map by computing new URLs from the exported identifiers rather than by pairing old and new crawls. Because the new permalink is a deterministic function of the post ID, date and slug under the structure you have chosen, the mapping can be derived exactly — no fuzzy matching, no near-miss pairings, and no ambiguity where two posts share a slug. Pairing two crawls, by contrast, requires guessing which new URL corresponds to which old one, and it guesses wrong precisely on the posts with duplicate or edited slugs.
4. Deploy the Map at the Server Layer
Server rules fire before PHP loads, so they are faster and survive plugin failures. On Apache, place rules in .htaccess above the WordPress block; on Nginx, add a map and a redirect.
# .htaccess — must sit ABOVE the "# BEGIN WordPress" block so it wins
Redirect 301 /2019/03/old-post-name/ /technical/old-post-name/
RedirectMatch 301 ^/\?p=123$ /technical/old-post-name/
# Nginx — map old paths to new, then redirect on a hit
map $request_uri $wp_redirect {
/2019/03/old-post-name/ /technical/old-post-name/;
}
server {
if ($wp_redirect) { return 301 $wp_redirect; }
}
5. Choose Plugin Rules Only Where Server Rules Cannot Reach
If you lack server access (managed WordPress hosts often block .htaccess edits), import the same map into the Redirection plugin. It works but adds a PHP round-trip per redirect, so reserve it for hosts where server rules are impossible.
# Export the Redirection plugin's rules so the map stays in version control
wp redirection export 1 json > redirection-rules.json
The order of operations here is unusually unforgiving, because the information you need to build the redirect map stops existing the moment you change the structure.
Deploy at the server rather than in the application for the same reason as any bulk map: it costs nothing per request, it can be staged and tested before the structure changes, and it is visible in version control. WordPress installations accumulate redirect mechanisms — the built-in guessing, one or more plugins, sometimes rules in .htaccess from a previous migration — and consolidating the bulk map into one server-level file is what makes the estate reviewable at all.
Worked Example
Before: https://www.example.com/2019/03/migrating-dns/ returns HTTP/1.1 200. After changing the structure to /%category%/%postname%/, that URL would 404. With the .htaccess rule Redirect 301 /2019/03/migrating-dns/ /dns/migrating-dns/ in place:
$ curl -sIL https://www.example.com/2019/03/migrating-dns/
HTTP/1.1 301 Moved Permanently
Location: https://www.example.com/dns/migrating-dns/
HTTP/1.1 200 OK
The old dated URL now resolves in a single hop to the new category-based permalink, preserving the inbound link equity.
WordPress will handle some of this for you, and understanding exactly how much is what stops the platform and your server rules from fighting.
Flush the rewrite rules explicitly after the change rather than waiting for it to happen. WordPress regenerates its internal rules lazily, so the new permalinks can return 404 for a period that depends on caching layers nobody is thinking about at that moment — and a 404 on the new structure during the window when you are testing the redirect map produces a confusing set of results. Flush, then confirm a new-format URL returns 200 before testing any redirect.
Verification
- Confirm a single-hop 301 on a sample of old URLs:
curl -sIL https://www.example.com/2019/03/migrating-dns/ | grep -iE '^HTTP|^location'. - Re-crawl the old URL list (
old-urls.csv) and confirm zero 404s and zero chains longer than one hop. - In Google Search Console, watch the legacy URL pattern move from Indexed to Redirect over the following crawl cycles.
FAQ
Should I use the Redirection plugin or server rules?
Use server rules (.htaccess or Nginx) when you have server access — they fire before PHP loads, so they are faster and keep working even if WordPress is down. Use the Redirection plugin only on managed hosts that block server-level redirect edits.
Will changing permalinks lose my rankings? Not if every old URL 301s to its exact new equivalent in a single hop. Rankings drop when old URLs 404, redirect to the homepage, or pass through a chain — generate a deterministic map and verify it before you announce the change.
Do I still need server rules if WordPress redirects old slugs itself? Yes, for anything beyond the simplest cases. The built-in handling covers some slug changes by guessing from the database, which is genuinely useful as a backstop and unreliable as a plan: it costs a query on every miss, it does not cover structural changes such as removing a date prefix, and its behaviour is not something you can inspect, version, or test. Generate a server-level map from the export, deploy that, and let the built-in guessing catch whatever falls through.
How do I handle a redirect plugin that already has rules in it? Export them first, then decide per rule. Plugin rules live in the database rather than in any repository, which makes them invisible to review and a frequent source of a hop nobody can locate — but some of them will be genuinely load-bearing, added years ago for reasons nobody remembers. Fold the ones that still receive traffic into the mapping inventory so they become server rules like everything else, and disable the plugin’s handling for paths the server now owns rather than leaving both active.
How long should the old permalink redirects stay in place? Indefinitely for anything with inbound links, which on a blog of any age is most of the archive. Dated permalinks in particular accumulate links from other sites, email newsletters and social posts that will never be updated, so the rules that serve them are doing real work years later. Retire individual rules only when the access logs show traffic to that source has genuinely decayed to nothing — which is a question the logs answer directly and a schedule cannot.
Related
← Back to CMS & Framework Routing Changes