If you run a WordPress production install, you’ve probably registered a custom URL pattern with add_rewrite_rule(), called flush_rewrite_rules(), and expected the route to just work. Here’s the thing: flush_rewrite_rules() only rebuilds the rewrite_rules option from whatever rules are currently registered. It won’t fix a rule that points at the wrong query variable, a rule that collides with an existing internal rewrite, or a regex that never matches because it was built on a wrong assumption about how WordPress parses requests. For small-to-mid publishing teams, this stings because a broken rewrite rule usually looks like a caching issue, a permalink problem, or a theme conflict. The real failure is sitting in the wp_options table, inside the rewrite_rules array, or in the WP_Rewrite object before the request ever reaches the template loader.
This article is about the failure mode that survives a rewrite flush. It’s not a beginner’s guide to pretty permalinks. I’m assuming you already know WordPress stores compiled rewrite rules in the rewrite_rules option and that flush_rewrite_rules() deletes and rebuilds that option. The problem is narrower: a rule can be sitting in the database, visible in wp rewrite list, and still never fire because it was registered in a way that can’t match the request or can’t produce a valid query.

What flush_rewrite_rules() Actually Does
flush_rewrite_rules() calls WP_Rewrite::flush_rules(). That method deletes the rewrite_rules option and then calls WP_Rewrite::wp_rewrite_rules() to rebuild the array from the rules that are currently registered. The key phrase is “currently registered.” If your rule was registered on init with a bad regex, a bad redirect, or a bad query string, the flush will happily write that bad rule back into the database. The flush doesn’t validate anything. It doesn’t test the rule against a sample URL. It doesn’t warn you that the rule will never match.
You can confirm this with a minimal plugin or mu-plugin:
add_action( 'init', function () {
add_rewrite_rule( '^bad-route/([^/]+)/?$', 'index.php?bad_var=$matches[1]', 'top' );
} );
flush_rewrite_rules();
After running that code, the rule exists in the database. wp rewrite list shows it. But the rule won’t produce a valid query unless bad_var is a public query variable. If it’s not, WordPress won’t populate $wp_query->query_vars['bad_var'], and the request will fall through to a 404 or to a different rule. The flush did its job. The registration didn’t.
The Core Failure: A Rule That Cannot Resolve to a Query
WordPress rewrite rules are not standalone routes. They’re regex-to-query-string translations. A rule only works if the query string on the right side maps to a query variable that WordPress recognizes. Public query variables come from WP_Query, from registered post types and taxonomies, and from the query_vars filter. If you write a rule that points to index.php?custom_slug=$matches[1] but never register custom_slug as a public query var, the rule is dead on arrival.
This is the most common version of the problem in production. A developer adds a rule, flushes, tests one URL, sees a 404, and then starts disabling plugins or clearing caches. The actual fix is usually one of two lines:
add_filter( 'query_vars', function ( $vars ) {
$vars[] = 'custom_slug';
return $vars;
} );
Or, if the rule is meant to map to an existing post type or taxonomy, the query string should use the correct key, such as post_type, name, p, page_id, category_name, or a custom taxonomy query var.
You can inspect the current public query vars with WP-CLI:
wp eval 'var_dump( $wp_query->public_query_vars );'
If your custom key isn’t in that list, no amount of flushing will make the rule work.
Conflicting Rules and the ‘top’ vs ‘bottom’ Problem
Another failure that survives a flush is a rule that’s registered correctly but never reached because an earlier rule matches the same URL pattern. WordPress compiles rewrite rules into a large associative array. The order of that array isn’t the order in which you called add_rewrite_rule(). It’s determined by the internal rule groups: post rules, page rules, date rules, comment rules, search rules, author rules, and so on. Your custom rule is appended to the extra_rules_top or extra_rules group depending on the third argument.
If you pass 'top', the rule is added to extra_rules_top, which is placed before most internal rules. If you pass 'bottom', it’s added to extra_rules, which is placed after many internal rules. The problem is that “top” doesn’t mean “first.” It means “before the default internal groups.” A page rule or a post rule can still match first in some configurations, especially if your regex is too broad.
Consider this rule:
add_rewrite_rule( '^([^/]+)/?$', 'index.php?custom_page=$matches[1]', 'top' );
That regex matches every single-segment URL. It will intercept /about/, /contact/, and /2024/. If you have pages with those slugs, the page rewrite may still win because page rules are compiled into a specific position. The result is unpredictable unless you inspect the final array.
Use WP-CLI to see the actual order:
wp rewrite list --format=table
Look for your rule in the output. If it appears after a rule that matches the same URL, your rule will never fire. The fix is to make the regex more specific, change the rule group, or use a different mechanism such as a custom endpoint or a custom post type rewrite slug.

Regex Assumptions That Break After the Flush
WordPress rewrite rules are regular expressions. A rule that works in a local test can fail in production because the URL structure is different. The most common assumption is that the request path always starts with the site’s home path. On a subdirectory install, the request path passed to the rewrite engine is relative to the WordPress directory, not the domain root. A rule written for a root install won’t match on a subdirectory install unless the regex accounts for the base.
Another assumption is that query strings are not part of the rewrite match. They’re not. The rewrite engine matches against the path only. If your rule expects a query string parameter to be part of the match, it will never fire. The query string is parsed separately and is available in $_GET and in the query vars after the rewrite match.
A third assumption is that the regex delimiter and escaping are correct. WordPress uses # as the delimiter for some internal rules, but add_rewrite_rule() expects a regex without delimiters. If you include delimiters, the rule will be stored with them and won’t match. This is a silent failure: the rule is in the database, the flush succeeded, and the URL still 404s.
Test your regex outside WordPress first. Use preg_match() in a standalone PHP file or in wp shell:
wp shell
$pattern = '^bad-route/([^/]+)/?$';
$subject = 'bad-route/hello';
var_dump( preg_match( '#' . $pattern . '#', $subject, $matches ) );
If the test fails, the rule will fail in WordPress. The flush isn’t the problem.
When the Rule Is Correct but the Query Is Not
There’s a subtler failure mode. The rule matches, the query var is public, and the request still doesn’t load the expected content. This happens when the query string on the right side of the rule doesn’t produce a complete WP_Query. For example, a rule that maps to index.php?post_type=event&event_slug=$matches[1] will match, but WP_Query won’t know that event_slug is the slug for the event post type unless the post type is registered with 'query_var' => 'event_slug' and the rewrite slug is set correctly.
The same problem occurs with custom taxonomies. A rule that maps to index.php?event_type=$matches[1] won’t load a taxonomy archive unless event_type is the query var for a registered taxonomy. The rule is present, the flush is clean, and the request still falls through to the main query with no results.
You can debug this by hooking into request and logging the parsed query vars:
add_filter( 'request', function ( $query_vars ) {
error_log( print_r( $query_vars, true ) );
return $query_vars;
} );
Then request the URL and check the debug log. If your custom query var is missing or empty, the rule matched but the query didn’t resolve. The fix is in the post type or taxonomy registration, not in the rewrite rule.
Flush Timing and the Init Hook
Another specific problem is that flush_rewrite_rules() is often called at the wrong time. If you call it before your rules are registered, the flush writes an empty or incomplete rule set. If you call it on every init, you’re rebuilding the option on every request, which is a performance problem and can cause race conditions on high-traffic sites.
The correct pattern is to register rules on init and flush once, usually on plugin activation or theme switch. For a production install maintained by a small team, the safest approach is to use WP-CLI after deploying the rule change:
wp rewrite flush
That command regenerates the rules from the current codebase. It doesn’t fix a bad rule, but it does ensure that the flush happens after all rules are registered. If you’re debugging a rule that survives a flush, run the WP-CLI command and then immediately inspect the rule list. If the rule is present but the URL still fails, the problem is in the rule definition, not the flush.
Inspecting the Stored Rules Directly
The rewrite_rules option is a serialized array in wp_options. You can inspect it with SQL:
SELECT option_value FROM wp_options WHERE option_name = 'rewrite_rules';
The value is serialized. You can unserialize it with WP-CLI:
wp eval '$rules = get_option( "rewrite_rules" ); foreach ( $rules as $regex => $query ) { if ( false !== strpos( $regex, "bad-route" ) ) { echo $regex . " => " . $query . PHP_EOL; } }'
This shows you exactly what WordPress will match against. If the regex isn’t what you intended, the rule is broken. If the query string points to an unregistered query var, the rule is broken. If the rule is missing entirely, the flush didn’t run or the rule wasn’t registered at flush time.

A Reproducible Failure Case
Here’s a complete failure case that you can reproduce on a clean WordPress install. It shows the exact problem: a rule that’s present after a flush but never fires.
add_action( 'init', function () {
add_rewrite_rule( '^team/([^/]+)/?$', 'index.php?team_member=$matches[1]', 'top' );
} );
add_action( 'init', function () {
flush_rewrite_rules();
}, 20 );
After loading the site once, run:
wp rewrite list --format=table | grep team
The rule appears. Now request /team/alice/. The result is a 404 or a fallback to the main query. The reason is that team_member isn’t a public query var. The rule matched, but WordPress couldn’t populate the query var, so the main query had no way to load a team member.
The fix is to register the query var:
add_filter( 'query_vars', function ( $vars ) {
$vars[] = 'team_member';
return $vars;
} );
Then flush again. The rule now works. The flush was never the problem. The rule was incomplete.
Why This Matters for Publishing Teams
Small-to-mid publishing teams often maintain their own production installs. They don’t have a dedicated platform team to debug rewrite rules. When a custom URL stops working after a deploy, the first instinct is to flush permalinks, clear the cache, or restore a backup. Those actions don’t fix a rule that was registered incorrectly. The result is lost time and a lingering fear that WordPress routing is fragile.
The durable fix is to treat rewrite rules as code, not configuration. Register them in version control. Test the regex before deploying. Verify that every query var on the right side of the rule is public. Inspect the compiled rule array after every flush. If a rule is present but not firing, the problem is in the rule definition, not in the flush.
For a deeper look at what to check when a new WordPress site returns nothing found, see What to Fix First When a New WordPress Site Says Nothing Found. That article covers the broader 404 diagnosis path, including permalink structure and server configuration, which are often confused with rewrite rule failures.
FAQ
Why does my rewrite rule show in wp rewrite list but still 404?
The rule is stored in the rewrite_rules option, but it may not match the request path, or it may match and produce a query string that WordPress cannot resolve. Check the regex against the actual request path and verify that every query var on the right side is public.
Does flush_rewrite_rules() fix a broken add_rewrite_rule() call?
No. The flush only regenerates the stored rules from the currently registered rules. If the registered rule has a bad regex, a bad query string, or an unregistered query var, the flush writes the same broken rule back into the database.
How can I tell if a query var is public in WordPress?
Run wp eval 'var_dump( $wp_query->public_query_vars );' or inspect the query_vars filter output. If your custom key isn’t in the list, WordPress won’t populate it from a rewrite rule.
What is the difference between ‘top’ and ‘bottom’ in add_rewrite_rule()?
The third argument controls whether the rule is added to extra_rules_top or extra_rules. “Top” places the rule before most internal rules, but it doesn’t guarantee first match. A broad regex can still be intercepted by a more specific internal rule. Inspect the final rule order with wp rewrite list.
Should I call flush_rewrite_rules() on every init?
No. That rebuilds the rewrite_rules option on every request, which is wasteful and can cause race conditions. Register rules on init and flush once on activation, theme switch, or after a deploy using wp rewrite flush.