A 404 loop is what you get when a URL resolves to a 404 internally, WordPress answers with a redirect to the URL it believes is correct, and that URL resolves to a 404 again. The browser follows Location headers until it hits its own limit — twenty hops in Chrome — then gives up with ERR_TOO_MANY_REDIRECTS. No error page renders. The client only ever sees redirects, which is why nobody files this as a 404 problem and why it sits in the ticket queue for a week.
The version I keep running into on self-maintained installs: a plugin registers a custom rewrite rule whose regex overlaps the slug namespace of content you published years ago, and the rule table — which resolves requests by order, not intent — routes the URL to a query that cannot see the record. The parts worth naming before the trace: WP_Rewrite, which compiles the rules; WP::parse_request(), which walks them and takes the first match; $wp->matched_rule, which records the winner; the rewrite_rules option, where the compiled table sits cached; and redirect_guess_404_permalink(), the core function that turns your 404 into a redirect and, on a collision like this, into a loop.

The Symptom: 301s to the Same URL Until the Browser Gives Up
The report that reaches you will be vague — "the whitepaper page won’t load." The DevTools Network tab, Preserve log checked, is anything but vague: the same request repeated about twenty times, every response a 301, and the Location header on each one identical to the URL that was asked for. The redirect target is the request. That is the signature. The rest of this article is just naming which component produces it.
Two neighboring symptoms are worth ruling out first. A plain 404 renders your 404 template — the rule table and the content both missed. An empty result set renders "Nothing Found" with a 200 status — the query ran, found nothing, and the template handled it politely. If your symptom is the second one on a fresh install, that is a different failure with a different fix; start with what to fix first when a new WordPress site says nothing found and come back here. This piece is about the loop.
Step 1: Reproduce the Loop Without the Browser
Browsers truncate redirect chains and cache 301s aggressively, so reproduce with curl before touching anything else. One hop is all you need:
curl -sS -o /dev/null -D - https://example.test/resources/whitepaper/
The capture:
HTTP/2 301
location: https://example.test/resources/whitepaper/
content-type: text/html; charset=UTF-8
That Location header is the diagnosis in miniature. Follow the chain with a limit and let curl report what the browser already told you:
curl -sS -L --max-redirs 8 -o /dev/null https://example.test/resources/whitepaper/
# curl: (47) Maximum redirects exceeded
Exit code 47 is CURLE_TOO_MANY_REDIRECTS. You now have a reproduction that fits in a terminal, and a command that will later tell you — unambiguously — whether the fix worked.
Step 2: Find Which Rewrite Rule Won
The mechanics first, because the trace only makes sense against them. WP_Rewrite compiles every permalink structure, post type, taxonomy, and add_rewrite_rule() call into one ordered array of regex => query pairs, then caches it in the rewrite_rules option. When a request arrives, WP::parse_request() walks that array and stops at the first regex that matches. No scoring, no specificity contest. First match wins, and the winner is written to $wp->matched_rule. Rules registered at position top are prepended, ahead of everything core generates; rules registered bottom land after all of it.
Query Monitor shows this directly — its Rewrite Rules panel lists the full table and flags the rule that matched the current request. Install it on staging if it is not there already; for this class of bug it is the cheapest ten minutes of the whole investigation.
For a trace that survives caching layers and does not depend on a toolbar, drop this into mu-plugins on staging. Logging parsed query vars on production is noise you do not need:
<?php
/**
* Plugin Name: Rewrite Trace
* Description: Logs the matched rule and parsed query vars for /resources/ requests.
*/
add_action( 'parse_request', function ( $wp ) {
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return;
}
if ( 0 !== strpos( $_SERVER['REQUEST_URI'], '/resources/' ) ) {
return;
}
error_log( 'matched_rule: ' . var_export( $wp->matched_rule, true ) );
error_log( 'matched_query: ' . var_export( $wp->matched_query, true ) );
error_log( 'query_vars: ' . var_export( $wp->query_vars, true ) );
} );
Reload the looping URL and read the log:
matched_rule: '^resources/([^/]+)/?$'
matched_query: 'name=whitepaper&resource_hub=1'
query_vars: array ( 'name' => 'whitepaper', 'resource_hub' => '1' )
The rule that won is a custom one, sitting above everything else in the table. Confirm the ordering from the command line — the rule table is order-sensitive, and you want line numbers, not a story:
wp rewrite list --fields=match,query | grep -n resources
On the site in question, trimmed:
14:^resources/([^/]+)/?$ index.php?name=$matches[1]&resource_hub=1
207:resources/([^/]+)/?$ index.php?webinar=$matches[1]
Rule 14 belongs to a plugin updated on Tuesday. Rule 207 is the permalink rule WordPress generated for the webinar post type — registered years ago with rewrite slug resources, which is where every /resources/{slug}/ URL has lived since. Line 14 wins. That is the entire mechanism; the remaining steps just prove it from both ends.

Step 3: Prove the Query Missed the Real Record
The winning rule sets two query vars: name=whitepaper and a plugin flag, resource_hub=1. With only name set, WP_Query defaults the post type to post, and the webinar record is invisible to the query the rule built. Query Monitor’s Queries panel shows the main query verbatim:
SELECT wp_posts.ID FROM wp_posts
WHERE wp_posts.post_name = 'whitepaper'
AND wp_posts.post_type = 'post'
AND wp_posts.post_status = 'publish'
LIMIT 1
-- Result: 0 rows
Zero rows on a single-post query means WP::handle_404() flags the request as a 404. Meanwhile the record plainly exists. One query settles that:
SELECT ID, post_type, post_name, post_status
FROM wp_posts
WHERE post_name = 'whitepaper';
Run it through wp db query if you would rather not open a SQL client. The result:
ID post_type post_name post_status
84 webinar whitepaper publish
So the content is published, its permalink is /resources/whitepaper/, and the rule table routes that URL to a query that cannot see it. The plugin’s own lookup never even got the chance to miss — the main query had already decided the request was dead.
Step 4: Watch the Guess Redirect Close the Loop
Left there, this would be an ordinary 404 and a short article. The loop arrives because core tries to help. redirect_guess_404_permalink(), hooked to template_redirect, fires on 404 requests that carry a name query var: it looks for a published post whose slug starts with the requested name — a prefix match, post_name LIKE 'whitepaper%' — and 301s to the first hit. The prefix match is why WordPress 6.3 added a strict_redirect_guess_404_permalink filter; by default, close is close enough.
The search finds webinar 84. The permalink of webinar 84 is /resources/whitepaper/ — the same URL the rule table already mishandles. The browser follows the Location, parse_request runs again, rule 14 matches again, the query misses again, the guess fires again. The whole chain, numbered:
- Browser requests
/resources/whitepaper/. - The custom rule matches first; query vars become
name=whitepaperandresource_hub=1. - The main query runs with post type
post, finds zero rows, and the request is flagged 404. redirect_guess_404_permalink()finds webinar 84 and 301s to its permalink.- The permalink is
/resources/whitepaper/. Return to step 1 — forever, or until the browser’s redirect limit.
The Root Cause in One Sentence
Two features claimed the same URL pattern, and the rule table broke the tie by registration order — which, between two plugins, is the order their callbacks run on init, which is the order stored in the active_plugins option. Deactivate and reactivate one plugin and the tie breaks the other way. Nobody had to check what already lived at /resources/{slug}/ before adding a rule for it, and nothing in core makes the collision loud. The loop was simply the failure that happened to be visible.
The two registrations, side by side:
// Plugin update, registered at 'top'
add_rewrite_rule(
'^resources/([^/]+)/?$',
'index.php?name=$matches[1]&resource_hub=1',
'top'
);
// On the site since 2019: a post type that owns the same prefix
register_post_type( 'webinar', array(
'public' => true,
'rewrite' => array( 'slug' => 'resources' ),
) );
The plugin also had to whitelist its flag through the query_vars filter — but that part is bookkeeping, not cause.
The Fix, in Order of Preference
1. Give the feature its own prefix
The clean fix: one prefix, one owner. Point the plugin’s rule at /resource-hub/{slug}/ and let /resources/ mean the webinar archive, permanently:
add_rewrite_rule(
'^resource-hub/([^/]+)/?$',
'index.php?name=$matches[1]&resource_hub=1',
'top'
);
If any hub URLs were already public, add explicit 301s from the old pattern to the new one. That list is small, enumerable, and under your control — which a slug-space collision is not.
2. Drop the rule to the bottom of the stack
Changing top to bottom puts the plugin’s rule after every core-generated rule. One line, no new URLs. The honest caveat: in this exact collision it does nothing useful, because the webinar rule matches the same pattern and swallows every request first — your rule becomes dead code. It is the right quick fix only when your regex is structurally distinct from core’s (deeper path, different prefix), which is worth knowing before you reach for it.
3. Keep the URL and add an explicit fallback
If both features genuinely must keep /resources/{slug}/, stop relying on rule order and make the resolution deterministic. The request filter runs after the rule table has parsed the query vars — the last point where you can still change what the main query will ask for:
add_filter( 'request', function ( array $vars ) {
if ( empty( $vars['resource_hub'] ) || empty( $vars['name'] ) ) {
return $vars;
}
if ( ! resource_hub_record_exists( $vars['name'] ) ) {
// Not ours. Hand the slug back to the webinar post type.
unset( $vars['resource_hub'] );
$vars['post_type'] = 'webinar';
}
return $vars;
} );
Unknown slugs fall through to the webinar post type; known hub slugs keep the flag and render the hub template. Two tradeoffs. Every request under the prefix now pays a dataset lookup, and hub records shadow webinars that share a slug. Write down which dataset wins, because the next maintainer will not guess.
4. Turn off the guess redirect — last resort
add_filter( 'redirect_guess_404_permalink', '__return_false' );
This stops the loop by removing its engine: 404s stay 404s. It does not fix the URL — the webinar still 404s, just quietly. And you lose slug-guessing everywhere on the site, which is doing real work for typo traffic and renamed posts. Only worth it alongside a proper 301 map, and only if you can enumerate what you are giving up.
5. Flush the rules, or nothing changes
The compiled table lives in the rewrite_rules option and is only rebuilt when you flush it. Editing the PHP does nothing until then:
wp rewrite flush --hard
--hard also rewrites .htaccess when your stack uses one; on nginx it is the same flush without the file. Flush once per rule change, from WP-CLI. Never flush on every page load — that rewrites the option on every request, and I keep finding exactly that on client builds, usually added "to be safe."

Verification: Prove the Loop Is Gone, Then Prove You Broke Nothing Else
Re-run the step 1 reproduction against both patterns:
curl -sS -o /dev/null -w 'status=%{http_code} redirects=%{num_redirects}' https://example.test/resources/whitepaper/
# status=200 redirects=0
curl -sS -o /dev/null -w 'status=%{http_code} redirects=%{num_redirects}' https://example.test/resource-hub/demo-sku/
# status=200 redirects=0
In Query Monitor, the matched rule for the webinar URL is now resources/([^/]+)/?$ with query var webinar=whitepaper, and the main query runs with post type webinar. Then the regression pass, because rewrite changes never break the thing you were looking at:
- Every hub record URL: 200 with the hub template, not the webinar template.
- Webinar pagination (
/resources/page/2/) and comment paging. - Feeds under the prefix (
/resources/feed/). - Trailing-slash and non-trailing-slash variants of both patterns.
- One post URL outside the prefix, and one arbitrary page.
- The
wp rewrite listoutput, diffed against the pre-fix capture.
Last wrinkle, and it has cost people afternoons: 301s are cached hard. Test with curl or a fresh browser profile, and purge the CDN — otherwise you will fix the loop and still see it in your own browser, at which point the site is fine and your cache is not.
FAQ
Why do I see "too many redirects" instead of a 404 page?
Each request resolves to a 404 internally, but the response the browser receives is a 301 — the guess redirect fires on template_redirect, before any template loads. The 404 template never renders. The browser just counts hops and gives up at its limit, twenty in Chrome.
Why does my custom rule beat the post permalink rule?
Rewrite rules are an ordered array of regex => query pairs, and WP::parse_request() takes the first regex that matches. add_rewrite_rule() with top prepends the rule ahead of everything core generates, so it wins every URL its pattern can swallow — including URLs that already belong to a post type.
Do I have to flush rewrite rules after changing them?
Yes. The compiled table is cached in the rewrite_rules option; PHP changes do nothing until a flush runs. Use wp rewrite flush --hard, once, from WP-CLI. Flushing on every page load rewrites the option on every request — a self-inflicted performance failure.
How is this different from a page that just says "Nothing Found"?
"Nothing Found" is a 200: the query ran, returned an empty result set, and the template handled it. A rewrite collision produces a real 404 — or this loop. Fresh install, empty front page, no redirects in sight? That is the other article, linked above.
Where This Goes Next
The habit worth stealing from this one: audit the rule table whenever a plugin ships a rewrite rule, the same way you read a diff. wp rewrite list before and after the update, diffed, catches this in review instead of in a support ticket. Next in this lane: redirect_canonical ping-pong — the www-versus-bare-domain, http-versus-https, trailing-slash loops that look identical in the Network tab and live somewhere else entirely in core.











