Change your permalink structure on an established site and most old URLs will not 404. Core catches the 404 and guesses the new permalink with a LIKE 'slug%' query against post_name, which usually works and sometimes sends the reader to a different post entirely.
Pages are the exception, and they get nothing at all. Every mechanism below was read from WordPress/wordpress-develop trunk on 2026-09-30.
Where the permalink structure is actually stored
Two options, not one.
permalink_structure holds the template you picked in Settings > Permalinks, for example /%postname%/. It is a short string and it is the only part a human edits.
rewrite_rules holds the compiled result: a serialized array of regular expressions mapped to query strings. That array is what WP matches every front-end request against, and it is derived from the first option, never edited directly.
wp option get permalink_structure
wp rewrite list --format=table | head -20
On a site with a handful of post types and a few plugins, rewrite_rules is routinely several hundred entries. It is one of the larger rows in wp_options.
What happens to old URLs when you change the structure
Nothing is rewritten, and no redirects are stored. The old URL simply stops matching a rule, so WordPress serves a 404.
Then redirect_canonical() runs on template_redirect, notices is_404(), and calls redirect_guess_404_permalink() as its last resort (wp-includes/canonical.php). If the failed URL left a name query var behind, that function looks the post up directly:
$where = $wpdb->prepare( 'post_name LIKE %s', $wpdb->esc_like( get_query_var( 'name' ) ) . '%' );
It refines that with year, monthnum and day when those query vars are also set, then returns get_permalink( $post_id ) and issues a 301.
This is why moving from /%year%/%monthnum%/%postname%/ to /%postname%/ feels harmless. The date-based rules are gone, but the request still parses far enough to leave name=my-post in the query, the guess finds the post, and the reader lands on the right page.
It is also why nobody notices what the guess is really doing.
The guess is a prefix match, so it can pick the wrong post
Read that query again. The default is LIKE 'my-post%', not an equality check.
A request for /hello/ will match hello-world, hello-again and hello, and $wpdb->get_var() returns whichever row the database hands back first. There is no ordering clause and no tie-break on relevance.
On a small blog that is a curiosity. On a site with a decade of posts and slugs that share prefixes, it is a 301 to the wrong article, which is much worse than a 404 because nothing reports it as an error.
Since WordPress 5.5 there is a filter that turns the guess into an exact match:
add_filter( 'strict_redirect_guess_404_permalink', '__return_true' );
That trades wrong-post redirects for honest 404s. If you are about to change a permalink structure and then hand-build a redirect map, the strict version is the one you want, because a loose guess will mask the URLs your map is missing.
You can also switch the guessing off entirely with do_redirect_guess_404_permalink.
Renaming a page's slug gets you no redirect at all
The other rescue mechanism is wp_old_slug_redirect(), which reads _wp_old_slug post meta written by wp_check_for_changed_slugs() when a published post's slug changes.
Both functions skip hierarchical post types. wp_check_for_changed_slugs() returns early on them, so the old slug is never recorded, and wp_old_slug_redirect() returns early too with the comment Do not attempt redirect for hierarchical post types.
Pages are hierarchical. So renaming a page's slug stores no history, and the guess cannot help either: a failed page request populates pagename, not name, and redirect_guess_404_permalink() returns false when name is empty.
Rename /about-us/ to /about/ and the old URL is a hard 404 from that moment. This is the single most common way a site quietly loses its best-linked URL, and none of the top-ranking permalink guides mention it.
Posts are fine, because post is not hierarchical. The asymmetry is the trap.
Why /%postname%/ changes which rules win
WP_Rewrite::init() sets one property from your structure:
if ( preg_match( '/^[^%]*%(?:postname|category|tag|author)%/', $this->permalink_structure ) ) {
$this->use_verbose_page_rules = true;
} else {
$this->use_verbose_page_rules = false;
}
When that is true, the generated array puts $page_rewrite before $post_rewrite. When it is false, posts come first.
The reason is that /%postname%/ and a page path are indistinguishable regexes: /contact/ could be either. Verbose mode resolves the ambiguity in favour of pages, which is why a post and a page cannot share a slug under a root-level %postname% structure.
Switch to a date-based structure and that ambiguity disappears, because posts now carry a date prefix. Switch back and it returns.
If you have ever seen a page start resolving as a post after a structure change, this ordering is why.
Saving Settings > Permalinks is not a no-op, and flushing is not automatic
WP_Rewrite::wp_rewrite_rules() reads the cached option and only rebuilds when it is empty:
$this->rules = get_option( 'rewrite_rules' );
if ( empty( $this->rules ) ) {
$this->refresh_rewrite_rules();
}
So a plugin that registers a new rewrite rule contributes nothing until something clears that option. That is the whole reason the "just visit Settings > Permalinks" advice works: loading the screen triggers a flush.
There is a detail worth knowing in refresh_rewrite_rules(). If wp_loaded has not fired yet, core does not save, because the rule set may be incomplete.
It defers the write to wp_loaded instead, so a rebuild triggered very early in the request writes nothing at that moment.
A hard flush also rewrites the managed block of your .htaccess, and that write can fail silently. We covered that mechanism in what core actually writes to .htaccess.
From the command line, the flush is explicit:
wp rewrite flush # soft: rebuilds the rewrite_rules option
wp rewrite flush --hard # also rewrites the .htaccess block
What to test before you change the structure on a live site
The structure change itself takes a second. The damage is in the URLs nobody is watching, so test against a real copy of the real content rather than a fresh install with three posts.
Clone production into a throwaway site with the SandyWP Cloner plugin, change the structure there, and check four things:
- A sample of real old URLs. Take fifty from your analytics or your sitemap and curl them. Note which return 200 after a 301, which 404, and which 301 to a different post than the one you asked for.
- Every page slug. These are the hard 404s, and they will not show up in a spot check of post URLs.
- Plugin and custom post type rules. Anything registered through
add_rewrite_rule()or a customrewriteslug is regenerated too, and conflicts surface at the new ordering rather than the old one. - Hardcoded links in content. Absolute URLs written into posts, widgets and page-builder blocks are not rewritten by anything. Our notes on wp search-replace and serialized data cover finding them.
A loop over the first list is enough to catch the wrong-post case:
while read -r path; do
final=$(curl -s -o /dev/null -w '%{http_code} %{url_effective}' -L "https://your-sandbox.sandywp.dev$path")
echo "$path -> $final"
done < old-urls.txt
Compare the effective URL against what you expected, not just the status code. A 200 at the wrong destination is the failure this post is about.
If you prefer to work in a shell on the copy, SSH and SFTP are available on the sandbox, and wp rewrite list there will show you the compiled rules for both structures side by side if you save the output before and after.
When a sandbox is the wrong tool for this
A copy tells you what breaks. It cannot tell you what it costs.
You cannot test how quickly Google recrawls, how much ranking a 301 chain leaks, or how your CDN caches the new URLs. Those need the change made on production and then watched in Search Console over weeks.
A sandbox also will not decide whether the change is worth making. For an established site with good rankings, the honest answer is usually that it is not, and that the best permalink structure is the one you already have.
What the copy is genuinely good for is the boring part: proving that your redirect map covers every URL that matters, before a single visitor hits a 404. That is the part that takes an afternoon and the part everyone skips.
The short version
- Changing the structure stores no redirects. Core guesses instead, with
post_name LIKE 'slug%'. - The loose guess can 301 to the wrong post.
strict_redirect_guess_404_permalinkmakes it exact. - Pages get neither mechanism, so a renamed page slug is a permanent 404 until you add a redirect.
rewrite_rulesis a cached option, and nothing rebuilds it until something empties it.- A root-level
%postname%structure resolves ambiguous paths as pages, not posts.
Build the redirect map first, verify it against a clone, then change the setting. Create a sandbox and you can have the whole thing checked before lunch.
