A literary-review site registers a custom taxonomy called character. The intent is editorial: tag posts by the fictional character they discuss—Romeo, Holden, Humbert—so readers can browse everything about one character in a single archive. The taxonomy works. The term archive at /character/romeo/ loads. Editors add terms. Six months pass. Then someone creates a WordPress page titled “Character” for a manifesto about the site’s editorial philosophy. The page slug is character. Now /character/ loads the page. And /character/romeo/? It 404s. Or worse: it loads the page with romeo as a child that doesn’t exist, returning the parent page content with a 200 status. The taxonomy archive is gone. No plugin was updated. No code changed. The collision was always there—latent in the rewrite rules, waiting for an editor to create the wrong page.
This is not a WordPress bug. It is a naming collision between two independent systems—editorial content and code-level schema—that share one namespace (URL slugs) with no coordination layer between them. In systems engineering terms, this is the same class of failure that distributed systems teams address through explicit naming registries, as covered in Google’s SRE book discussions of managing critical state. WordPress rewrite rules are that system here, and the slug character is the critical state.
That same discipline applies to naming decisions: before publishing, editors need a way to test labels, roles, and public-facing language stay consistent, which is where a character name generator that fits the project can function as a planning aid rather than a substitute for domain evidence.
What register_taxonomy() Actually Writes to the Rewrite Table
When you call register_taxonomy(), WordPress does several things. It inserts the taxonomy into the global $wp_taxonomies array. It registers the taxonomy’s query vars. And—critically—it adds rewrite rules to the rewrite rules array, which is stored in the rewrite_rules option in wp_options. The slug you pass as the rewrite argument (or the taxonomy name itself, if you don’t override it) becomes the URL prefix for term archives.
register_taxonomy( 'character', 'post', array(
'rewrite' => array(
'slug' => 'character',
'with_front' => true,
'hierarchical' => false,
),
'public' => true,
'show_in_rest' => true,
));
This call generates rewrite rules that match character/([^/]+)/?$ and map it to index.php?character=$matches[1]. The character query var is registered, and WordPress knows that when that query var is set, it should load a taxonomy archive template. So far, so good. The rules are generated on init, flushed to the database, and stored. The system is coherent.
But the rewrite_rules option is an ordered array. WordPress matches incoming URLs against this array in sequence—the first rule that matches wins. The order is not alphabetical. It is not by registration time. It is determined by WP_Rewrite::rewrite_rules(), which generates rules in a specific priority: rules for specific post types, then taxonomy rules, then date archives, then search, then pagination, then the catch-all page rule ((.?.+?)(?:/([0-9]+))?/?$) that matches anything that looks like a page path.
That catch-all page rule is the key. When you register character as a taxonomy slug, the taxonomy rule character/([^/]+)/?$ sits above the page rule in the array. So /character/romeo/ matches the taxonomy rule first. Good. But /character/ itself—without a term slug—does not match the taxonomy rule (which expects a term after the slug). It falls through to the page rule. And if no page with slug character exists, it 404s. If a page with slug character does exist, it matches the page rule and loads the page. The taxonomy archive for the taxonomy itself (the “all characters” view) was never generated by register_taxonomy()—only term archives were. So the page fills the vacuum.
The Latent Collision: Why It Surfaces Months Later
The failure mode is latent because the taxonomy works fine until the page is created. The rewrite rules don’t change. The page rule was always there, matching character as a potential page slug. There was just no page to match. When an editor creates the page, they are not modifying rewrite rules—they are creating a row in wp_posts with post_name = 'character'. But the rewrite engine doesn’t know the difference between “no page exists” and “a page exists but doesn’t match this URL.” It just tries rules in order, and the page rule matches character because character is a valid page-slug pattern.
The deeper problem is that the editorial team and the development team are using the same namespace—URL slugs—without a shared registry. The developer chose character as the taxonomy slug because it reads well in URLs. The editor created a page called “Character” because it reads well as a page title. Neither party knew the other had claimed the slug. Editors and developers both face naming-collision problems, and just as writers use tools like an character name generator to avoid name clashes in fiction, WordPress teams need a shared slug registry to avoid collisions in URLs. When editors name pages and developers name taxonomies without coordination, collisions are not a bug—they are an expected failure mode of an uncoordinated system.
This is also why the collision surfaces months later. The developer registered the taxonomy during the build. The editor created the page during a content sprint six months in. The time gap makes the failure feel mysterious—nothing changed in the code!—but the rewrite rules were always vulnerable. The page creation was the trigger, not the cause. The cause was the absence of a naming contract between editorial and development.
Tracing the Conflict: Reading rewrite_rules and query_vars
When you encounter this 404-or-wrong-page in production, the first instinct is usually wrong. You might check the taxonomy registration code, confirm the taxonomy is registered, confirm the term exists, and conclude the rewrite rules are “broken.” They are not broken. They are resolving correctly according to their priority order—you just don’t know what that order is. Here is how to trace it.
Step 1: Dump the rewrite_rules array
Run this with WP-CLI:
wp eval 'global $wp_rewrite; print_r( $wp_rewrite->rewrite_rules() );'
Or inspect the option directly:
wp option get rewrite_rules --format=json | jq 'to_entries[] | select(.key | startswith("character"))'
You will see something like this:
[character/([^/]+)/?$] => index.php?character=$matches[1]
[character/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$] => index.php?character=$matches[1]&feed=$matches[2]
[(.?.+?)(?:/([0-9]+))?/?$] => index.php?pagename=$matches[1]&page=$matches[2]
The taxonomy rules are above the page catch-all. So /character/romeo/ should match the taxonomy rule. If it does not, the rules were not flushed after the taxonomy was registered, or something modified the array order. But if the rules look correct and you still get a 404 or wrong page, the problem is not in the rules array—it is in what happens after the rule matches.
Step 2: Inspect $wp_query->query_vars at template_redirect
Add a temporary debug hook:
add_action( 'template_redirect', function() {
global $wp_query;
if ( isset( $_GET['debug_query'] ) ) {
wp_die( var_export( $wp_query->query_vars, true ) );
}
});
Navigate to /character/romeo/?debug_query=1. You will see the query vars that WordPress resolved from the rewrite. If the taxonomy rule matched, you should see 'character' => 'romeo' in the array. If instead you see 'pagename' => 'character/romeo' or 'pagename' => 'character', the page rule won—meaning the taxonomy rule did not match, even though it appears earlier in the array.
The most common reason: the term slug is not romeo. Editors may have named the term “Romeo Montague” with slug romeo-montague. The URL /character/romeo/ does not match any term, so the taxonomy query returns empty, and WordPress falls back to the page rule. The 404 is correct behavior—the URL is wrong. But the failure feels like a rewrite bug because the taxonomy “used to work” (it did, for the terms that existed at the time).
Step 3: Check for reserved query_var collisions
WordPress has a list of reserved query vars in WP::$public_query_vars. If your taxonomy name or rewrite slug matches one of these, the query var registration silently fails or behaves unexpectedly. Check with:
wp eval 'global $wp; print_r( $wp->public_query_vars );'
If character appears in that array from another plugin or a custom registration, your taxonomy’s query var is competing. The rewrite rule points to ?character=romeo, but if two systems registered character as a query var, the resolution depends on which pre_get_posts callback runs last—a separate race condition that compounds the slug collision.
The Priority Order: Why the Page Rule Sometimes Wins
WordPress generates rewrite rules in WP_Rewrite::rewrite_rules() by iterating through registered post types, taxonomies, and other rule generators in a specific order. The rough priority is:
- Per-post-type rules (feeds, trackbacks, embeds, comments)
- Per-taxonomy rules (term archives, feeds)
- Date archive rules
- Search rules
- Pagination rules
- Root-level rules (home, front page)
- The page catch-all:
(.?.+?)(?:/([0-9]+))?/?$
The page catch-all is intentionally last among the “named” rules because pages are the most generic URL pattern in WordPress—any hierarchical path could be a page. This is why /about/team/ loads a page, not a taxonomy term called “team.” But it is also why a page slug that collides with a taxonomy slug creates ambiguity: the taxonomy rule should win for /character/romeo/, but /character/ itself has no taxonomy rule to match (taxonomy rules expect a term), so the page rule fills the gap.
This is not a bug in the priority order. It is a design decision: pages are the fallback for any URL that doesn’t match a more specific rule. The failure is not in WordPress’s resolution logic—it is in the assumption that character as a taxonomy slug and character as a page slug can coexist without conflict. They cannot. They share a namespace, and the namespace has no collision detection.
The Fix: Treat Slugs as System Identifiers
The immediate fix is to rename one of the two. Either change the taxonomy rewrite slug to something that will not collide with editorial page names (e.g., characters plural, or by-character), or rename the page. Changing the taxonomy slug requires a rewrite flush and, if the site has been indexed, redirects from the old term archive URLs to the new ones:
register_taxonomy( 'character', 'post', array(
'rewrite' => array(
'slug' => 'by-character',
'with_front' => true,
),
// ...
));
// After registration, flush:
// wp rewrite flush
// Add redirects for old URLs:
add_action( 'template_redirect', function() {
if ( is_404() ) {
$req = $_SERVER['REQUEST_URI'];
if ( preg_match( '#^/character/([^/]+)/?$#', $req, $m ) ) {
wp_safe_redirect( home_url( "/by-character/{$m[1]}/" ), 301 );
exit;
}
}
});
The deeper fix is to treat taxonomy slugs as system identifiers, not as human-readable labels. The slug character was chosen because it reads well in URLs, but it is also a word an editor might naturally use as a page title. The slug by-character is less likely to collide because it is not a natural page name—just as a writer using Reedsy’s character name generator picks names that fit a specific namespace and won’t clash with existing characters in the story.
The systems-engineering response is to create a shared naming registry. This does not need to be a complex tool. It can be a README in the theme repository that lists all registered taxonomy slugs, post type slugs, rewrite endpoints, and reserved query vars. Before an editor creates a page, they check the registry. Before a developer registers a taxonomy, they check the registry. The registry is the coordination layer that WordPress does not provide.
Here is a minimal version of what that registry should document:
- Taxonomy slugs: The
rewrite['slug']value for everyregister_taxonomy()call, with the URL pattern it generates. - Post type slugs: The
rewrite['slug']value for everyregister_post_type()call, with the archive URL and single URL pattern. - Rewrite endpoints: Every
add_rewrite_endpoint()call and the URL suffix it adds. - Reserved page slugs: A list of slugs that editors must not use for pages because they conflict with registered system identifiers.
- Query vars: Every custom query var registered via
add_filter( 'query_vars', ... ), to detect collisions with$wp->public_query_vars.
This registry is the schema-level documentation that prevents the latent collision. Without it, you are relying on memory and luck—two things that do not scale across a team or across six months of content creation.
Preventing the Next Collision: A Registration Audit
If you are inheriting a site where collisions may already be latent, run a registration audit. List all registered taxonomies and post types, their rewrite slugs, and check each against existing page slugs in wp_posts:
wp eval '
$taxonomies = get_taxonomies( array(), "objects" );
$post_types = get_post_types( array(), "objects" );
$slugs = array();
foreach ( $taxonomies as $tax ) {
if ( isset( $tax->rewrite["slug"] ) ) {
$slugs[ $tax->rewrite["slug"] ] = "taxonomy: " . $tax->name;
}
}
foreach ( $post_types as $pt ) {
if ( isset( $pt->rewrite["slug"] ) ) {
$slugs[ $pt->rewrite["slug"] ] = "post_type: " . $pt->name;
}
}
global $wpdb;
foreach ( $slugs as $slug => $source ) {
$conflicts = $wpdb->get_var( $wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s AND post_status = %s",
$slug, "page", "publish"
));
if ( $conflicts > 0 ) {
echo "COLLISION: slug \"$slug\" ($source) conflicts with a published page\n";
}
}
'
This will not catch every collision—hierarchical pages with matching parent slugs, or pages with slugs that match only part of a rewrite pattern, can also cause problems. But it will catch the most common case: a page slug that exactly matches a taxonomy or post type rewrite slug.
Run this audit after any register_taxonomy() or register_post_type() change, and after any bulk page import. Treat the output as a production incident if it finds a collision—not because the site is down, but because the collision will surface as a 404 or wrong-content response the next time an editor or crawler hits the affected URL.
Conclusion: Slugs Are Schema, Not Labels
The WordPress rewrite system is coherent. It resolves URLs according to a deterministic priority order, and it behaves correctly given the rules it has. The failure is not in the system—it is in the gap between the system’s assumptions and the team’s practices. The rewrite engine assumes that slugs are unique across all rule generators. The team treats slugs as human-readable labels that can be chosen independently by editors and developers. Those two assumptions cannot both hold.
The fix is not a plugin, a hook, or a rewrite rule. It is a naming contract: a shared registry of system identifiers that both editorial and development teams consult before claiming a slug. The contract is simple, low-tech, and boring. It is also the only thing that prevents the next register_taxonomy() call from colliding with the next page an editor creates six months from now. Treat slugs as schema. Document them. Audit them. And when a 404 traces back to a slug collision, treat it as a naming-registry failure, not a rewrite bug—because that is what it is.











