Debug a WordPress 404 by Matching the Rewrite Rule First
Last edited on August 15, 2026

A custom WordPress URL can return 404 even when wp rewrite list --match prints rows. The rows are only candidate regular expressions. The decisive question is whether one candidate produces the internal query that the route-owning code expects, and whether that exact query is present in the stored rewrite_rules option.

Start read-only. Name the request path and expected internal query, save the current rule table, compare candidate rows with that expectation, and call the handler through its query variable. Flush only when the owning code is active, the handler works, and the intended stored rule is absent. Then use one database-only flush and verify both the intended path and a path that must remain invalid.

This distinction was reproduced with WordPress 7.0.4, WP-CLI 2.12.0, PHP 8.4.24 and MariaDB 11.8.6 in an isolated Debian 13.6 lab. Before the flush, WP-CLI returned two broad candidates but neither owned the route. The pretty URL returned 404 while the direct query returned 200. One soft flush stored the intended rule, the exact URL returned 200, an extra segment returned 404, and reversing the fixture restored the original 94-rule table.

Name the Route Before Touching the Rules

A WordPress rewrite rule has two parts: a regular expression that matches the path, and an internal query such as index.php?voxfor_route=$matches[1]. WordPress parses that query and exposes allowed query variables to the application. A route is not identified by a 200 status alone; it is identified by the path, the expected internal query and the code that registers both.

Reproduce the failure on a clone or WordPress staging area before changing the live database. If the 404 appeared after a move, complete post-migration path checks around DNS, redirects, SSL and critical URLs before blaming the rule table.

Our tested fixture used this exact contract:

  • Request path: voxfor-lab/alpha/
  • Expected regex: ^voxfor-lab/([a-z0-9-]+)/?$
  • Expected query: index.php?voxfor_route=$matches[1]
  • Direct control: /?voxfor_route=alpha
  • Owner: the active voxfor-rewrite-lab plugin

WordPress documents this in its add_rewrite_rule reference documents the regex, query and top-or-bottom insertion position. Its add_rewrite_tag reference covers query-variable registration; the fixture returns a small text response when that value exists.

<?php
add_action( 'init', function () {
    add_rewrite_tag( '%voxfor_route%', '([^&]+)' );
    add_rewrite_rule(
        '^voxfor-lab/([a-z0-9-]+)/?$',
        'index.php?voxfor_route=$matches[1]',
        'top'
    );
} );

add_action( 'template_redirect', function () {
    $value = get_query_var( 'voxfor_route', '' );
    if ( '' === $value ) {
        return;
    }

    status_header( 200 );
    nocache_headers();
    header( 'Content-Type: text/plain; charset=UTF-8' );
    echo "route=voxfor-lab\n";
    echo 'value=' . sanitize_key( $value ) . "\n";
    exit;
}, 0 );

On an existing site, do not add this fixture. Find the equivalent add_rewrite_rule, query-variable registration and handler in the component that claims the broken route. If the expected plugin cannot register anything because its files changed, run WP-CLI checksum triage before you flush; regeneration cannot recreate code that is missing or modified.

Capture the site context and persisted table before diagnosis. Set every variable for one known route. The temporary directory is evidence storage, not a backup destination.

set -Eeuo pipefail
umask 077

WP_PATH=${WP_PATH:?Set WP_PATH to the staging WordPress root}
SITE_URL=${SITE_URL:?Set SITE_URL to the staging origin}
REQUEST_PATH=${REQUEST_PATH:-voxfor-lab/alpha/}
EXPECTED_QUERY=${EXPECTED_QUERY:-'index.php?voxfor_route=$matches[1]'}
DIRECT_QUERY=${DIRECT_QUERY:-'voxfor_route=alpha'}
OWNER_PLUGIN=${OWNER_PLUGIN:-voxfor-rewrite-lab}

SITE_URL="${SITE_URL%/}"
REQUEST_PATH="${REQUEST_PATH#/}"
REQUEST_PATH="${REQUEST_PATH%/}/"
WORK_DIR=$(mktemp -d /tmp/voxfor-wp404-evidence.XXXXXX)
MARKER="$WORK_DIR/.voxfor-owned"
printf '%s\n' 'voxfor-wp404-evidence' > "$MARKER"

wp --path="$WP_PATH" core version
wp --path="$WP_PATH" plugin is-active "$OWNER_PLUGIN"
wp --path="$WP_PATH" option get permalink_structure
wp --path="$WP_PATH" option get rewrite_rules --format=json   > "$WORK_DIR/before-rules.json"

BASELINE_RULE_COUNT=$(jq 'length' "$WORK_DIR/before-rules.json")
BASELINE_RULE_SHA=$(sha256sum "$WORK_DIR/before-rules.json" |
  awk '{print $1}')
printf 'baseline rules=%s sha256=%s\n'   "$BASELINE_RULE_COUNT" "$BASELINE_RULE_SHA"

Treat the serialized rule set with the same option ownership discipline used for other WordPress database options: capture the current value, identify the owning code and change only the layer that failed. Do not edit the serialized option by hand.

A Candidate Match Is Not Ownership Proof

WP-CLI’s official rewrite list command supports --match=url and fields for match, query and source. Read all three. A broad page or attachment expression can match the text of a custom path while mapping it to an unrelated query.

Run the match against the path relative to the site origin:

wp --path="$WP_PATH" rewrite list   --match="$REQUEST_PATH"   --fields=match,query,source   --format=json > "$WORK_DIR/candidates.json"

jq -r '.[] |
  [.match, .query, .source] | @tsv'   "$WORK_DIR/candidates.json"

CANDIDATE_COUNT=$(jq 'length' "$WORK_DIR/candidates.json")
INTENDED_CANDIDATE_COUNT=$(jq   --arg expected "$EXPECTED_QUERY"   '[.[] | select(.query == $expected)] | length'   "$WORK_DIR/candidates.json")

printf 'candidate_rows=%s intended_query_rows=%s\n'   "$CANDIDATE_COUNT" "$INTENDED_CANDIDATE_COUNT"

In the stale lab, this returned two rows:

  • (.?.+?)(?:/([0-9]+))?/?$ mapped to a page query.
  • [^/]+/([^/]+)/?$ mapped to an attachment query.

Both expressions could consume voxfor-lab/alpha/. Neither produced voxfor_route. Nonempty output therefore narrowed the search but did not prove route ownership.

Now inspect the persisted option independently. The WP_Rewrite::wp_rewrite_rules reference explains that WordPress retrieves the stored rules and can regenerate them when absent. For this decision, count the exact expected query in the captured stored object.

wp --path="$WP_PATH" option get rewrite_rules --format=json   > "$WORK_DIR/current-rules.json"

STORED_RULE_SHA=$(sha256sum "$WORK_DIR/current-rules.json" |
  awk '{print $1}')
STORED_EXACT=$(jq   --arg expected "$EXPECTED_QUERY"   '[to_entries[] | select(.value == $expected)] | length'   "$WORK_DIR/current-rules.json")

jq -r --arg expected "$EXPECTED_QUERY" '
  to_entries[]
  | select(.value == $expected)
  | [.key, .value] | @tsv
' "$WORK_DIR/current-rules.json"

printf 'stored_sha256=%s stored_exact=%s\n'   "$STORED_RULE_SHA" "$STORED_EXACT"

Interpret the result precisely. stored_exact=0 means this table does not contain the expected internal query. stored_exact=1 means it does, but not that the handler or web server is healthy. More than one exact row deserves code review before mutation because two expressions may claim the same query contract.

Use the Handler as a Boundary Control

A direct query bypasses the pretty-path match while still entering WordPress. In the fixture, /?voxfor_route=alpha exercises query-variable admission and the handler. Compare it with the failing pretty URL and retain both bodies.

PRETTY_STATUS=$(curl -sS   -o "$WORK_DIR/pretty-before.txt"   -w '%{http_code}'   "$SITE_URL/$REQUEST_PATH")

DIRECT_STATUS=$(curl -sS -G   -o "$WORK_DIR/direct-query.txt"   -w '%{http_code}'   --data-urlencode "$DIRECT_QUERY"   "$SITE_URL/")

printf 'pretty_status=%s direct_query_status=%s\n'   "$PRETTY_STATUS" "$DIRECT_STATUS"

[[ "$PRETTY_STATUS" == 404 ]]
[[ "$DIRECT_STATUS" == 200 ]]
grep -Fxq 'route=voxfor-lab' "$WORK_DIR/direct-query.txt"
grep -Fxq 'value=alpha' "$WORK_DIR/direct-query.txt"

Those two responses create four materially different decisions:

  1. Pretty 404, direct 200, intended stored rule absent: WordPress can run the handler, but the stored path-to-query mapping is stale or missing. A bounded soft flush is now testable.
  2. Pretty 404, direct query not handled: stop. Fix query-variable registration, plugin loading, multisite/site context or handler code before touching rules.
  3. Intended stored rule exists, direct 200, pretty 404: stop flushing. Inspect whether the request reaches this WordPress installation, including virtual-host, document-root and front-controller handling.
  4. Pretty 200 but wrong content: the route matched something, but status alone does not identify the owner. Compare the actual query, body and component behavior.

This control is route-specific. A post, REST endpoint or ecommerce route may require authentication, a nonce or different expected semantics. Use the least privileged request that genuinely reaches the intended handler.

Flush Once Only When the Stored Table Is Stale

A flush is justified only after three facts agree: the owning code is loaded, the direct query reaches its handler, and the intended query is absent from the stored table. Do not loop flushes until the symptom disappears.

Before regenerating, the command below exports the staging database as a before-state receipt. It then runs wp rewrite flush without --hard. WP-CLI’s rewrite flush documentation makes --hard a separate option; on a single-site installation, hard mode also updates .htaccess. This workflow deliberately limits the change to regeneration of the database rules.

wp --path="$WP_PATH" plugin is-active "$OWNER_PLUGIN"
[[ "$STORED_EXACT" -eq 0 ]]
[[ "$DIRECT_STATUS" == 200 ]]

wp --path="$WP_PATH" db export   "$WORK_DIR/before-soft-flush.sql"   --add-drop-table --quiet

wp --path="$WP_PATH" rewrite flush

wp --path="$WP_PATH" option get rewrite_rules --format=json   > "$WORK_DIR/after-rules.json"
AFTER_RULE_SHA=$(sha256sum "$WORK_DIR/after-rules.json" |
  awk '{print $1}')
AFTER_STORED_EXACT=$(jq   --arg expected "$EXPECTED_QUERY"   '[to_entries[] | select(.value == $expected)] | length'   "$WORK_DIR/after-rules.json")

[[ "$AFTER_RULE_SHA" != "$BASELINE_RULE_SHA" ]]
[[ "$AFTER_STORED_EXACT" -eq 1 ]]
printf 'soft_flush before=%s after=%s stored_exact=%s\n'   "$BASELINE_RULE_SHA" "$AFTER_RULE_SHA" "$AFTER_STORED_EXACT"

A changed hash is supporting context, not success by itself. The useful postcondition is one intended query plus correct HTTP behavior. Verify the exact route and an extra segment that the anchored fixture regex must reject.

wp --path="$WP_PATH" rewrite list   --match="$REQUEST_PATH"   --fields=match,query,source   --format=json > "$WORK_DIR/after-candidates.json"

jq -e --arg expected "$EXPECTED_QUERY"   'any(.[]; .query == $expected)'   "$WORK_DIR/after-candidates.json" >/dev/null

EXACT_STATUS=$(curl -sS   -o "$WORK_DIR/exact-after.txt"   -w '%{http_code}'   "$SITE_URL/$REQUEST_PATH")
EXTRA_PATH="${REQUEST_PATH%/}/extra/"
EXTRA_STATUS=$(curl -sS   -o "$WORK_DIR/extra-after.txt"   -w '%{http_code}'   "$SITE_URL/$EXTRA_PATH")

[[ "$EXACT_STATUS" == 200 ]]
[[ "$EXTRA_STATUS" == 404 ]]
grep -Fxq 'route=voxfor-lab' "$WORK_DIR/exact-after.txt"
grep -Fxq 'value=alpha' "$WORK_DIR/exact-after.txt"

printf 'http exact=%s extra_segment=%s body_sha256=%s\n'   "$EXACT_STATUS" "$EXTRA_STATUS"   "$(sha256sum "$WORK_DIR/exact-after.txt" | awk '{print $1}')"

That negative path matters. Without it, changing a route from 404 to 200 could hide an overbroad expression that captures URLs owned by another plugin or page.

Representative output from the isolated run:

environment  wordpress=7.0.4  wp_cli=2.12.0  php=8.4.24  mariadb=11.8.6-MariaDB-0+deb13u1
baseline  rules=94  sha256=f93feed1aa9fbb4ba2f60f55594330f41bf4511922c64c013015a7c12bedfd6e
stale_state  plugin=active  wp_cli_candidate_matches=2  intended_query_matches=0  stored_exact=0
boundary_control  pretty_before=404  direct_query=200  handler_value=alpha
flush  mode=database_only  after_sha256=7d2665b64bc54c47fccfbee1920ff9aa468db5f2367d07dc7d55836fe4d1092b  stored_exact=1
matched_rule  match=^voxfor-lab/([a-z0-9-]+)/?$  query=index.php?voxfor_route=$matches[1]  source=other
http_verification  matched_path=200  extra_segment=404
rollback  plugin=inactive  rules_restored=yes  stored_exact=0  http_status=404
cleanup  lab_absent=yes

Accept the regeneration as justified only when the route-owning code is active, the direct query reaches the intended handler, the expected query was absent before the flush, one soft flush stores exactly one intended rule, the exact path returns the expected body, the extra segment remains rejected, and the before-state receipt is retained. A changed option hash or any 200 response alone is insufficient.

WordPress Rewrite-Rule Questions

Does any wp rewrite list --match output prove the correct rule exists?

No. The command lists candidate regular expressions that match the supplied path. A broad page, attachment or catch-all rule can appear even when the intended route is absent. Compare the query field with the internal query registered by the owning code, then confirm the same query exists in the stored rule table.

When should I flush WordPress rewrite rules?

Flush when a known code or permalink change should register a rule, the owning component is loaded, its handler can run through a direct query, and the expected rule is missing from the stored table. Use a staging copy, save the before state and flush once. A generic 404 without those facts is not enough.

What does wp rewrite flush change?

Without --hard, WP-CLI regenerates the database rewrite rules. The separate hard mode can also update .htaccess on single-site WordPress. This article uses only the soft form so a database-rule test is not mixed with a web-server configuration change.

Why can a direct query work while the pretty URL returns 404?

Handler and query-variable logic can be valid even when no stored regular expression translates the pretty path into that query. Direct-query success moves the failure boundary earlier, toward path matching or the request handoff, but it does not by itself prove which layer is wrong.

What if the intended rewrite rule is stored but the URL still returns 404?

Do not flush again. Confirm that the request reaches the expected WordPress installation and site, then inspect query-variable admission, handler conditions, plugin load order, content assumptions and the front-controller handoff. Server-generated 404 pages, a wrong document root or a different multisite context will not be repaired by regenerating the same table.

Reverse the Change and Keep the Receipt

Reverse proof in the lab deactivated only the fixture plugin, ran one soft flush and required the original rule hash to return. On a real site, do not deactivate a business-critical plugin merely to imitate the lab. Revert the exact route-owning deployment through its approved release mechanism, then regenerate once and run the same positive and negative checks.

This tested rollback block is valid only for the disposable fixture or an explicitly authorized owner-plugin rollback:

wp --path="$WP_PATH" plugin deactivate "$OWNER_PLUGIN" --quiet
wp --path="$WP_PATH" rewrite flush >/dev/null

wp --path="$WP_PATH" option get rewrite_rules --format=json   > "$WORK_DIR/rollback-rules.json"
ROLLBACK_RULE_SHA=$(sha256sum "$WORK_DIR/rollback-rules.json" |
  awk '{print $1}')
ROLLBACK_STORED_EXACT=$(jq   --arg expected "$EXPECTED_QUERY"   '[to_entries[] | select(.value == $expected)] | length'   "$WORK_DIR/rollback-rules.json")
ROLLBACK_STATUS=$(curl -sS   -o "$WORK_DIR/rollback-body.txt"   -w '%{http_code}'   "$SITE_URL/$REQUEST_PATH")

[[ "$ROLLBACK_RULE_SHA" == "$BASELINE_RULE_SHA" ]]
[[ "$ROLLBACK_STORED_EXACT" -eq 0 ]]
[[ "$ROLLBACK_STATUS" == 404 ]]

printf 'rollback rules_restored=yes stored_exact=0 status=%s\n'   "$ROLLBACK_STATUS"

[[ -f "$MARKER" ]]
[[ "$(<"$MARKER")" == 'voxfor-wp404-evidence' ]]
[[ "$WORK_DIR" == /tmp/voxfor-wp404-evidence.* ]]
find "$WORK_DIR" -depth -mindepth 1 -delete
rmdir "$WORK_DIR"

If the admission conditions fail, preserve the rule snapshots, database export, candidate rows and HTTP bodies; keep the current production database and route authoritative; and do not loop flushes, edit the serialized option or add an unrelated .htaccess rule. Revert only the route-owning code when that change is authorized, regenerate once, and escalate to the query handler or web-server boundary identified by the receipt.

Continue with advanced WordPress development guides when the route requires custom query variables or templates beyond this diagnosis. The durable operational result is not “the 404 disappeared.” It is a named rule owner, an exact internal query, one bounded mutation, a positive route check, a negative overmatch check and evidence that the change can be reversed.

Share this Post

Leave a Reply

Your email address will not be published. Required fields are marked *