TechEarl

WordPress Rewrite Rules Not Working? The Checklist

A diagnostic checklist for WordPress rewrite rules that won't fire: forgetting to flush, an unregistered query var, rule order, regex anchors, and how to inspect reality with wp rewrite list and parse_request.

Ishan Karunaratne⏱️ 13 min readUpdated
Share thisCopied
WordPress rewrite rules not working: a checklist covering the missing flush, an unregistered query var via the query_vars filter, rule order, and broken regex anchors, with wp rewrite list as the debugging tool.

If your WordPress rewrite rule is not firing, three causes explain about ninety percent of the cases I have hit:

  1. You never flushed. WordPress caches the full rule set in the rewrite_rules option. Your add_rewrite_rule() call runs on every request, but the rule does not go live until the cache is rebuilt. Flush once and it works.
  2. You added the rule but not the query var. The rule matches the URL and stuffs a value into a custom variable, then WordPress throws that variable away because it is not on the public query-vars list. The URL resolves to a 404 or the front page, and it looks like the rule "did nothing." This is the single most common reason a rule that is clearly correct still fails.
  3. Your regex is wrong, or another rule wins first. A missing ^/$ anchor, a greedy pattern, or a 'bottom' rule shadowed by a built-in rule that matches earlier.

Work the checklist below in order. It is sorted most-common-first, so you will usually fix it in the first two items without reading the rest.

1. You did not flush after adding the rule

Symptom: you added add_rewrite_rule(), reloaded the URL, and got a 404 (or the home page). Nothing about the rule is wrong; it just is not in the live set yet.

WordPress does not evaluate your add_rewrite_rule() call on every request to build routing. It builds the full rule array once, serializes it, and stores it in the rewrite_rules option in the database. Until that cache is regenerated, your new rule does not exist as far as the router is concerned.

The cheapest flush is manual: go to Settings, Permalinks and click Save Changes without changing anything. Loading that screen rebuilds and re-stores the rule set. With WP-CLI:

bash
wp rewrite flush

That is the fix for the largest single bucket of "rewrite not working" reports. Do not reach for code yet; confirm the rule is registered (the next item) and just flush.

2. The custom query var is not registered

Symptom: the rule clearly matches the URL (you can see it in wp rewrite list), but the value never reaches your template or handler. The page resolves to a 404 or the front page, as if the matched part of the URL evaporated.

This is the one that eats afternoons. A rewrite rule maps a URL pattern to an index.php?... query string:

php
add_rewrite_rule(
    '^reports/([0-9]+)/?$',
    'index.php?report_id=$matches[1]',
    'top'
);

The rule runs, the regex matches, $matches[1] is captured. But report_id is a custom query variable, and WordPress drops any query var that is not on its public allow-list before it ever hands control to your code. So the value is parsed, then immediately discarded. The rule "matched but nothing happened."

The fix is the query_vars filter. You have to tell WordPress that report_id is a legitimate variable it should keep:

php
add_filter( 'query_vars', function ( $vars ) {
    $vars[] = 'report_id';
    return $vars;
} );

After that, get_query_var( 'report_id' ) returns the captured value inside template_redirect, pre_get_posts, or your template. No flush is needed for the filter itself (it runs per request), but if you added it at the same time as the rule, flush once for the rule.

If you are routing to a fully custom endpoint rather than reusing the main query, the rewrite-API walkthrough shows the full add-rule-plus-query-var pattern end to end, and the custom URL structure and query vars piece goes deeper on how the captured values flow into the main query.

3. Rule order and priority: a 'bottom' rule gets shadowed

Symptom: the rule is registered and the query var is allowed, but a different page loads. Your ^reports/... URL resolves to a post, a paginated archive, or a category instead of your handler.

add_rewrite_rule() takes a third argument: 'top' or 'bottom' (the default is 'bottom'). WordPress evaluates rules in order and stops at the first match. A 'bottom' rule sits after all of WordPress's own rules, so if any built-in rule (the verbose page rule, the post permalink rule, an attachment rule) matches your URL first, yours never gets a chance.

php
// Evaluated AFTER core's rules. A page or post permalink can win first.
add_rewrite_rule( '^reports/([0-9]+)/?$', 'index.php?report_id=$matches[1]', 'bottom' );

// Evaluated BEFORE core's rules. Yours wins.
add_rewrite_rule( '^reports/([0-9]+)/?$', 'index.php?report_id=$matches[1]', 'top' );

Use 'top' when your pattern overlaps something WordPress already routes, which is most custom rules whose first path segment looks like a normal slug. The trade-off is that a 'top' rule can shadow a legitimate page of the same name, so read item 5 before you blanket everything with 'top'. To see the actual evaluation order, dump the rules (item 8); the array is ordered exactly as WordPress checks it.

4. Regex problems: anchors, greed, and unescaped segments

Symptom: the rule matches too much, too little, or not at all. Trailing slashes break it, or a longer URL matches a rule meant for a short one.

The pattern in add_rewrite_rule() is a real regular expression, matched against the request path with the leading slash already stripped. The usual mistakes:

  • No start anchor. Without ^, reports/([0-9]+) matches my-old-reports/12 too. Anchor the start with ^reports/.
  • No end anchor (and no optional trailing slash). ^reports/([0-9]+) with no $ matches reports/12/anything/else. The idiomatic ending is /?$, which accepts both reports/12 and reports/12/.
  • Greedy capture swallowing the slash. (.+) is greedy and eats / characters. If you want a single path segment, use ([^/]+); reserve (.+) for the genuine "rest of the path" case.
  • Unescaped literal. A literal dot in the pattern (reports\.json) must be escaped; a bare . matches any character.

A safe single-numeric-id pattern is ^reports/([0-9]+)/?$; a safe single-slug pattern is ^reports/([^/]+)/?$. Test the regex against your exact URLs before blaming anything else. (For the regex itself, mind the same pitfalls covered in any anchoring and quantifier reference: ^ and $ are not optional once a rule overlaps other routes.)

5. The rule conflicts with a page, post, or CPT of the same slug

Symptom: the URL works for some values and not others, or it loads a real page instead of your handler. You have a Page (or post, or custom-post-type entry) whose slug collides with your rule's static segment.

If you register ^reports/([0-9]+)/?$ and there is also a published Page with the slug reports, the two compete for /reports/... URLs. With a 'bottom' rule, WordPress's page rule wins and serves the Page. With a 'top' rule, your rewrite wins and the Page becomes unreachable at its own URL.

Pick a static segment that does not collide with any existing page, post, or custom-post-type slug, or rename the conflicting content. This is also why "just set everything to 'top'" is bad advice: it silently hides legitimate pages. Check for a slug clash before changing priority. WordPress's verbose-page-rules behavior makes this worse on sites with many nested pages, because core generates an explicit rule per page that can match ahead of yours.

6. You flush on every request, or never

Symptom: either the site is mysteriously slow on every page load, or your rule went stale and stopped matching after a deploy.

flush_rewrite_rules() is expensive. It re-reads every rule from every plugin and theme, rebuilds the array, and writes it to the database. Calling it on a normal hook like init on every request is a real performance bug, and the official guidance is explicit that it should never run on every page load.

The correct pattern is to flush once, on plugin or theme activation, after you register your rule:

php
register_activation_hook( __FILE__, 'te_rewrite_activate' );

function te_rewrite_activate() {
    // Register the rule first so the flush picks it up.
    add_rewrite_rule( '^reports/([0-9]+)/?$', 'index.php?report_id=$matches[1]', 'top' );
    flush_rewrite_rules();
}

The opposite failure is never flushing at all. If you add a rule in code, ship it, and the rule does not appear, you skipped the flush. Either run wp rewrite flush once after deploy or wire the activation hook above.

7. An mu-plugin has no activation hook, so the flush never ran

Symptom: you put the rewrite code in wp-content/mu-plugins/, it loads fine (you can confirm the rule is registered in code), but the URL still 404s and clicking through never fixes it.

Must-use plugins have no install or activation step. They load on every request, so there is no register_activation_hook() moment to hang a one-time flush on. People copy a regular-plugin example with an activation hook into an mu-plugin, and the activation callback simply never fires because activation never happens.

For mu-plugins, register the rule on init as normal but flush manually, once, out of band:

bash
wp rewrite flush

Do that after deploying the mu-plugin (or after any change to its pattern). Do not put flush_rewrite_rules() inside the mu-plugin's init callback to "fix" this; that puts you straight back into the every-request flush from item 6. Register in code, flush once from the CLI.

8. Inspect reality: wp rewrite list and the matched query vars

When the checklist above has not pinned it, stop guessing and look at what WordPress actually has. Two tools.

wp rewrite list shows the live rule set, in evaluation order, straight from the rewrite_rules option:

bash
wp rewrite list --format=table
wp rewrite list --match=/reports/12 --format=table

The --match form is the useful one: it shows which rule (if any) a specific URL hits, and what query string it rewrites to. If your rule is not in the list, you did not flush (item 1). If it is in the list but a different rule appears above it for your URL, that is the order problem (item 3).

To dump the in-memory rules from PHP, read $wp_rewrite->rules:

php
add_action( 'init', function () {
    global $wp_rewrite;
    error_log( print_r( $wp_rewrite->rules, true ) );
}, 99 );

Then watch what the request actually resolved to. Hook parse_request, which fires after WordPress has matched the URL to a rule and populated the query vars, and log both the matched rule and the parsed vars:

php
add_action( 'parse_request', function ( $wp ) {
    error_log( 'matched_rule: ' . print_r( $wp->matched_rule, true ) );
    error_log( 'matched_query: ' . print_r( $wp->matched_query, true ) );
    error_log( 'query_vars: ' . print_r( $wp->query_vars, true ) );
} );

This tells you, in one place: which rule matched (matched_rule), the raw query string it rewrote to (matched_query), and the final query_vars array. If your custom variable is missing from query_vars here, that is item 2, the unregistered query var. If matched_rule is a core rule and not yours, that is item 3.

A drop-in debug mu-plugin

Package the two logging hooks into a must-use plugin you can drop in and pull out:

php
<?php
/**
 * Plugin Name: TE Rewrite Debug
 * Plugin URI:  https://techearl.com/wordpress-rewrite-rules-not-working
 * Description: Logs the matched rewrite rule, the rewritten query string, and the parsed query vars on every front-end request.
 * Version:     1.0.0
 * Author:      Ishan Karunaratne
 * Author URI:  https://techearl.com
 * License:     GPL-2.0-or-later
 * Text Domain: te-rewrite-debug
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

function te_rewrite_debug_log_request( $wp ) {
    if ( is_admin() ) {
        return;
    }

    $report = array(
        'request'       => $wp->request,
        'matched_rule'  => $wp->matched_rule,
        'matched_query' => $wp->matched_query,
        'query_vars'    => $wp->query_vars,
    );

    error_log( 'te-rewrite-debug: ' . wp_json_encode( $report ) );
}
add_action( 'parse_request', 'te_rewrite_debug_log_request' );

function te_rewrite_debug_log_rules() {
    global $wp_rewrite;
    error_log( 'te-rewrite-debug rules: ' . wp_json_encode( $wp_rewrite->rules ) );
}
add_action( 'init', 'te_rewrite_debug_log_rules', 99 );

Drop it in wp-content/mu-plugins/te-rewrite-debug.php, load the failing URL once, read wp-content/debug.log (with WP_DEBUG_LOG on), then delete the file. It writes one JSON line per request, so the matched rule, the rewritten query, and the surviving query vars are all in one place. It registers no rewrite rule and never flushes, so it is safe to leave running on staging.

Verify the rule works

flushing the rewrite rules then confirming the custom rule is live with wp rewrite list
Real output: flush, then confirm the rule is actually registered.

Once you have applied a fix, confirm it rather than assuming:

  1. wp rewrite list --match=/reports/12 shows your rule winning for the test URL, rewriting to index.php?report_id=12.
  2. Load the URL. It returns your handler's output, not a 404 and not the front page. Check the response status, not just the visible page (a soft 404 can still render content).
  3. get_query_var( 'report_id' ) returns the captured value inside template_redirect. If it is empty, the query var is still not registered (item 2).
  4. The parse_request log (or the debug mu-plugin) shows matched_rule as yours and your variable present in query_vars.

If all four pass, the rule is live and routing correctly. The most common residual gotcha at this point is a page cache or object cache serving stale output; purge it and re-test against fresh HTML.

See also

Sources

Authoritative references this article was fact-checked against.

TagsWordPressPHPRewrite RulesWP-CLIDebugging

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

A WordPress Hosting Decision Tree for Agencies

Hosting choices for WordPress agency clients are operational decisions, not pricing decisions. The decision tree by traffic tier and workload type: shared, managed WordPress, managed VPS, self-managed VPS. Plus the agency-side implications of each.