A URL that should work returns a 404. The post exists, the slug is spelled right, the template file is in the theme, and loading the same address on another machine changes nothing. Then somebody opens Settings, Permalinks and clicks Save without touching a single field, and the URL starts working. Three weeks later, after a deploy, it breaks again.
That Save button did not change a setting. It rebuilt an array. Between the moment a request reaches PHP and the moment WP_Query runs, WordPress walks an ordered list of regular expressions, stops at the first pattern that matches the requested path, and turns the rest of that line into a query string. On this blog the list is 247 entries long. It lives in one option row, it is only rebuilt when something explicitly asks for it, and almost nobody has ever read it.
Nearly everything about permalinks that feels like superstition comes from that array being invisible: the ritual Save, the advice to “just flush the rules”, the custom post type that works on staging and 404s on live. This page makes the array visible. Paste your rules in, type the path that misbehaves, and watch the walk happen row by row.
What a WordPress rewrite rule actually is
A rewrite rule is a pair: a regular expression that is tested against the requested path, and a query string that WordPress runs if the expression matches. Both halves are plain strings. There is no object, no class, no registry with an API worth speaking of. The whole set is an ordered PHP array stored in wp_options under the name rewrite_rules, serialized. On wp-image-editor.com that row is 24,314 bytes and carries autoload set to auto, which means it is read into memory on every single request, front end and admin alike.
The query half always starts with index.php? and uses $matches[1], $matches[2] and so on as placeholders for the capture groups of the pattern. The page catch-all on this site, position 228 of 247, looks like this:
wp option get rewrite_rules --format=json
wp rewrite list --format=csv --fields=match,query,source
"(.?.+?)(?:/([0-9]+))?/?$" => "index.php?pagename=$matches[1]&page=$matches[2]"
Read that pattern out loud and the trouble becomes obvious. It matches almost any path with at least two characters in it. It is not the last rule in the list by accident, and anything placed below it has to get past it first.
The tester below takes those rules in any of the three shapes they turn up in and walks a request path down them the way WordPress does. Everything happens in your browser: the rules you paste are never uploaded anywhere, and there is no request to any server while it works.
Rewrite rule tester
Rewrite rules are the layer between a URL and a query. Paste the rules a site actually stores, then walk a request path through them: every rule that was tried, the one that matched first, what it captured, and what query that becomes. Everything is worked out in this browser tab, nothing is uploaded and nothing is fetched.
When the permalink structure begins with a variable tag, WordPress switches on verbose page rules: a rule that sets pagename is only accepted if a page with that exact path really exists, otherwise the walk continues at the next rule. Name the page paths that exist so the walk can do the same. Turn the box off to see the raw order without that check.
| Variable | Value | What it asks WordPress for |
|---|
| Variable | Value | What it asks WordPress for |
|---|
A pattern that is not in the list above was added after the last flush, so it lives in the code and nowhere else. A post type nothing mentions was registered with rewrite switched off, or its rules were never written.
All of this only decides what happens to a request that has already reached WordPress. Apache or nginx sees it first: if the server hands the URL to a file, to a directory listing or to its own 404 before index.php is ever reached, no rule here runs at all. When nothing matches for any URL, check that side first, then come back to this list.
First match wins
The entire decision lives in one loop in wp-includes/class-wp.php. Each pattern is tested with preg_match( "#^$match#", $request_match, $matches ), and on a hit the loop calls break. Two consequences follow, and between them they explain most permalink bugs.
The first is that the delimiters add an anchor at the front only. Nothing appends a $ for you. If your pattern ends without an end anchor, it matches every path that merely begins the right way, and it will happily swallow requests you never intended it to see. Every core rule ends in /?$ for exactly this reason.
The second is that position is everything. The array is assembled in WP_Rewrite::rewrite_rules() by merging fixed groups in a fixed order: rules from permastructs first, then robots, favicon, sitemaps, deprecated files, registration pages, the root rule, comments, search, author archives, date archives, then the page and post catch-alls, and finally the rules added with add_rewrite_rule() at the default position. On this site 176 of the 247 rules sit above robots.txt$, all of them generated from permastructs registered by post types, taxonomies and endpoints.
That ordering is the reason a properly registered custom post type is fine and a hand-written rule often is not. register_post_type() and register_taxonomy() add a permastruct, and permastruct output is merged into extra_rules_top, near the front of the list. add_rewrite_rule( $regex, $query, $after = 'bottom' ) defaults to the other end. Bottom means below the page catch-all, below the post catch-all, below everything: on this site, position 248 of 248.
This is what the tester calls a dead rule. It generates a sample path from each pattern with a small regex reader that understands groups, alternation, character classes, escapes and quantifiers, checks that the rule’s own regex really does match the sample it produced, and then tests that sample against every rule above it. Patterns with lookarounds, backreferences or named groups are refused rather than guessed at, and the number refused is reported instead of quietly dropped.
Verbose page rules, the exception nobody expects
If page rules sat above post rules and the walk stopped at the first regex hit, no post would ever load on a site using /%postname%/. WordPress handles this with a special case that is easy to miss. When the permalink structure begins with %postname%, %category%, %tag% or %author%, WP_Rewrite::$use_verbose_page_rules is set to true and the page rules are merged ahead of the post rules.
The walk then does something it does nowhere else: when a matched rule sets pagename from a capture, it calls get_page_by_path() on the captured value, and if no page exists at that path it runs continue and carries on down the list. It also checks the post status object, so a page in a status that is neither public, protected nor private is treated as a miss too. A regex match, in other words, is not always a match.
The tester models this with the verbose page rules checkbox and a list of page paths that exist, which you fill in yourself because the tool never reads your database. While the box is ticked, a pagename rule that would be rejected is not counted as a shadower in the dead-rule check, which is honest but can be surprising: untick it and the same rule is correctly reported as blocking everything below it. Both readings are useful. The first tells you what happens today, the second tells you what happens the moment somebody publishes a page with a colliding slug.
The flush, honestly
flush_rewrite_rules() is not a cache clear. It is a full rebuild. It calls WP_Rewrite::flush_rules(), which regenerates the complete array from every registered post type, taxonomy, endpoint and permastruct, runs the filters attached to each group, serializes the result and writes it back with update_option(). On this site that is 247 patterns rebuilt and roughly 24 KB written, plus the invalidation of the autoloaded options cache that every other request depends on.
With the default $hard = true it does more. After the rebuild it calls save_mod_rewrite_rules(), which rewrites the WordPress block inside .htaccess, and iis7_save_url_rewrite_rules() on IIS. That is a file write on a file the web server reads for every request, which is worth understanding before you trigger it casually; the htaccess explainer covers what actually sits in that block and what happens when the markers get mangled. You can suppress just the file write with the flush_rewrite_rules_hard filter and keep the option rebuild.
Calling this on init, or anywhere else that runs on every page load, means doing all of that on every page load. It is a classic plugin sin and it shows up as a site that is mysteriously slow under load, with a hot options table and an .htaccess file whose modification time keeps changing. There is one more subtlety worth knowing: flush_rules() checks whether wp_loaded has fired, and if it has not, it defers itself to that hook so plugins registering later still get their rules in. Flushing too early is not just wasteful, it can produce an incomplete array.
The correct place is an activation hook, with the post type registered first, or a single deliberate wp rewrite flush at the end of a deploy.
register_activation_hook( __FILE__, function () {
wpie_register_recipe_type(); // register_post_type() must run before the flush
add_rewrite_rule(
'recipes/([^/]+)/?$',
'index.php?post_type=recipe&name=$matches[1]',
'top' // the default is 'bottom'
);
flush_rewrite_rules();
} );
Two failure modes come out of this and the tester checks for both. The rules you added in code field takes one pattern per line and compares them against the stored list, which catches the rule that exists in your plugin but never made it into the option because nothing flushed. The post types and taxonomies field checks whether any pattern or query in the list mentions them at all, which catches the post type registered after the last flush.
What comes out the other end
Once a rule wins, four things happen to its query string in quick succession. Everything up to the last question mark is trimmed with preg_replace( '!^.+?!', '', $query ), which is greedy on purpose. Then WP_MatchesMapRegex::apply() substitutes the captures, and each one goes through PHP’s urlencode(), so a space becomes + and a slash becomes %2F. An optional group that did not participate yields an empty value rather than an error. The result is passed through addslashes() and then parse_str().
What lands in $wp->query_vars is not that array, though. WordPress then loops over $wp->public_query_vars, the list filtered by the query_vars filter, and copies across only the names that appear in it. A rule producing index.php?flavour=$matches[1] matches perfectly, produces a clean query, and then loses flavour entirely unless you registered it. Values from $_GET and $_POST are checked before the ones from the rule, so a query string parameter of the same name overrides what the rule captured.
The debugging technique for all of this is four lines long. Hook parse_request, which fires at the very end of the walk with the WP object passed by reference, and dump three properties.
add_action( 'parse_request', function ( $wp ) {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
error_log( print_r( array(
'request' => $wp->request, // the path, home path stripped
'matched_rule' => $wp->matched_rule, // the regex that won
'matched_query' => $wp->matched_query, // after substitution
'query_vars' => $wp->query_vars, // what WP_Query will receive
), true ) );
} );
An empty matched_rule on a path you expected to work means no rule matched at all. A populated matched_rule with the wrong pattern in it means something above your rule got there first. A correct rule with an empty query_vars entry means the variable was never registered. Those are three different bugs with three different fixes, and without this dump they all look like the same blank 404 page.
A diagnostic order for a 404 that should not be one
Work through these in order. Skipping ahead is how people end up flushing rules for an hour to fix a server misconfiguration.
- Does the request reach WordPress at all. Load
/index.php?p=123with a real post ID. If that works and the pretty URL does not, PHP is running and the problem is above WordPress or in the rules. If neither works, stop reading about rewrite rules and look at the server. - Is there a matching rule. Paste the stored rules into the tester with the path that fails. No match means the rule is missing, which is nearly always a flush that never happened, or a rule registered on a hook that runs too late.
- Does the matching rule produce the query you expect. Read the captures and the resulting variables. A rule that wins with
pagenamewhen you wantedpost_typeis a shadowing problem, and the trace marks every rule further down that would also have matched. - Does that query find a post. This is where the rewrite layer ends and
WP_Querybegins. Checkpublicly_queryable, the post status, the slug that is actually stored, and whether the term exists. A perfect rule pointing at a draft is still a 404.
One case deserves calling out because it wastes so much time. If the URL returning 404 is an image file rather than a post, the rewrite layer is innocent: static files are served by the web server and never reach index.php. That is a file path problem or a media problem, and the media library manager side of WunderPaint, with its usage analysis, orphan detection and duplicate finder, will find the broken reference faster than any rule trace. The same goes after a bulk conversion: if you have run files through the image processor and changed extensions, the old URLs are gone at the filesystem level, not at the rewrite level.
Where the tester stops
The patterns are compiled as JavaScript regular expressions, not PCRE. For the syntax that appears in real rewrite rules, character classes, quantifiers, optional groups and alternations, the two behave identically. For a construct only PCRE has, they do not, and the tool tells you rather than pretending. It also refuses to guess at patterns with lookarounds, backreferences or named groups when it generates sample paths.
It does not read a live site. The rules, the page paths and the list of post types all have to be pasted or typed in, which is the price of running with no server and no credentials. It does not evaluate the Apache or nginx layer either, and the interface says so plainly, because rules in this array only ever run for requests that already reached WordPress. The query string of a request is not part of matching at all and is called out as such when you paste a whole URL. There are limits on size for sanity: the dead-rule check is skipped above 200 rules, the list is capped at 800, and at most 240 trace rows are drawn.
Anything you paste stays inert. Every value the interface shows you goes in through textContent or createElement, never innerHTML, and a path containing markup is proven harmless by an assertion in the tool itself. There is no fetch, no XHR and no external asset.
The array is the site
Rewrite rules are one of those pieces of infrastructure that only announce themselves when they break, in the same family as mail authentication, where a contact form goes quiet for weeks before anybody checks the SPF, DKIM and DMARC records. Nothing in the WordPress admin shows you the list. There is no screen that says which rule matched your last request. The one control the interface offers, the Save button on the Permalinks screen, does something far larger than its label suggests and gives no report on what changed.
Once you can see the array, the folklore collapses into ordinary engineering. A custom post type 404s because its rules are not in the stored option, and they are not there because nothing flushed after registration. A URL suddenly starts resolving to the wrong template because someone published a page whose slug happens to satisfy a pattern higher up the list. A rule that worked in a tutorial does nothing on your site because it was added at the bottom, under a pattern that matches almost everything. Each of those is visible in a trace and invisible without one.
Keep the habit small. Export the rules once with wp rewrite list, keep the file next to your plugin, and when a URL misbehaves, walk it before you touch anything. Flush deliberately, once, at activation or at deploy, and never on a hook that runs for visitors. The 247 lines that decide what your site does with a request deserve to be read at least once by the person responsible for them.