search expand

How to Trace a 404 Loop Caused by a Custom Rewrite Rule That Overlaps a Post Slug

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.

Team of developers seated around a table with open laptops, working through a failing request together

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.

Two developers leaning in toward a desktop monitor to inspect a debugging panel

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:

  1. Browser requests /resources/whitepaper/.
  2. The custom rule matches first; query vars become name=whitepaper and resource_hub=1.
  3. The main query runs with post type post, finds zero rows, and the request is flagged 404.
  4. redirect_guess_404_permalink() finds webinar 84 and 301s to its permalink.
  5. 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."

Developers reviewing PHP rewrite rule changes in a code editor on a laptop

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 list output, 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.

The Difference Between add_theme_support(‘editor-styles’) and Actually Loading the Right CSS


“}

Let me write out every block with proper escaping. Done thinking; produce answer.{“title”:”The Difference Between add_theme_support(‘editor-styles’) and Actually Loading the Right CSS”,”html”:”

Every self-maintained WordPress install eventually produces the same support ticket: the post editor does not look like the published site. Serif fallback fonts in the canvas, wrong content width, links in the wrong color — while the front end renders exactly as designed. Nine times out of ten the root cause is a two-line ritual copied with one line intact. add_theme_support( 'editor-styles' ) is a declaration: it tells the block editor that your theme intends to style the editing canvas. It loads nothing. The CSS arrives through a second call — add_editor_style() — or through theme.json, which is a different mechanism entirely. This article separates those channels cleanly: what the flag actually does, what delivers bytes, how to trace which channel broke using WP-CLI and a settings-payload log, the root causes ranked by how often they occur, and a baseline setup you can verify in one sitting. If you maintain the editor for a small publishing team, this distinction is the difference between a ten-minute fix and a recurring ticket queue.

Editorial team reviewing rendered page styles around a shared office monitor
The scene of the ticket: two people, one canvas, and a Network panel with nothing in it.

The symptom: a canvas that ignores your theme

Open any draft in the block editor. The canvas — the area inside the iframe where the post body renders — falls back to the browser default serif at the browser default size. Colors and font-size presets from theme.json may still apply, which is the misleading part: the editor looks half-styled, so everyone assumes editor styles are “partly working” and starts editing CSS that was never loaded at all.

Now open your two usual instruments. Query Monitor’s Styles panel lists everything enqueued through the WP_Styles API on post.php — your editor stylesheet will not appear there, even on a healthy install. The browser Network panel shows no request for editor-style.css, also on a healthy install. Neither instrument can tell you whether editor styles loaded, because editor styles do not travel through either path. The CSS is read on the server and inlined into the canvas document; there is no enqueue to list and no HTTP request to observe.

That is the trap in one sentence: the symptom looks like a CSS bug, both instruments are silent by design, and the actual defect is one function call short of a contract. The discipline is the same one that applies when a fresh install renders “Nothing Found” instead of posts — reproduce, trace the core path, and only then change code: what to fix first when a new WordPress site says Nothing Found.

What add_theme_support( ‘editor-styles’ ) actually does: nothing, on purpose

Call it the opt-in. add_theme_support( 'editor-styles' ) writes an entry into the global $_wp_theme_features array and returns. No file is read, no path resolved, no CSS generated. You can confirm what it did with one command:

wp eval 'var_dump( current_theme_supports( "editor-styles" ) );'
bool(true)

The flag has two observable effects. First, the block editor will honor whatever the global $editor_styles array contains when the editor screen builds its settings; without the flag, that array is ignored — the registrations exist, the editor simply declines the delivery. Second, historically, it is what moved your canvas into an iframe: since WordPress 5.4, themes opting into editor styles had their post content rendered in a separate document so theme CSS could own the full cascade without fighting the editor chrome. Current versions render the canvas in an iframe regardless, but the flag remains the delivery switch.

The classic editor never needed this. TinyMCE loads add_editor_style() registrations without asking for support — which produces the other recurring confusion: a theme that styled the classic editor correctly in 2016 and “stopped” when the block editor arrived. The flag is a block-editor requirement, and the function reference says as much.

The full contract is two calls, and the ways it fails are predictable:

// functions.php
add_action( 'after_setup_theme', function () {
    add_theme_support( 'editor-styles' );              // 1. Opt in. Loads nothing.
    add_editor_style( 'assets/css/editor-style.css' ); // 2. Deliver. Registration only — still no CSS moved.
} );

Line 1 without line 2 is a promise with no shipment. Line 2 without line 1 ships to a closed dock as far as the block editor is concerned, though the classic editor will still accept it.

The three channels that actually load CSS

add_editor_style(): the file channel

add_editor_style() appends path strings to the global $editor_styles array. That is all it does at call time — it does not verify the file exists, enqueue anything, or inline anything. Delivery happens later, when the editor screen assembles its settings: core resolves each registered path against the active stylesheet’s root (the child theme’s root, if a child theme is active), reads the file server-side, and passes the raw CSS to the block editor inside the settings payload. The editor then inlines it into the canvas iframe as a <style> element.

Three consequences follow, and all of them shape how you debug:

  • No HTTP request is made for a local editor stylesheet, so the Network panel is blind to it.
  • The CSS bypasses the WP_Styles queue, so Query Monitor is blind to it.
  • A registered path that does not resolve to a real file is skipped — silently. No warning, no log line, no 404. Silence is the failure mode.

theme.json: the declarative channel

A theme.json file is the other first-class channel, and it needs neither of the two calls. Its styles and styles.blocks sections compile into CSS that core applies to the editor and the front end; WordPress 6.1 and later also accept raw CSS under styles.css. Typography and color presets arrive as CSS custom properties in both contexts. If your editor shows correct colors and preset sizes while body copy still renders in the wrong typeface, this is why: the declarative channel is working, the file channel is not. The theme.json handbook covers the full surface.

enqueue_block_editor_assets: the chrome channel

The third channel is the enqueue_block_editor_assets hook, and it does not do what people hope. Styles enqueued there load into the parent document — the editor chrome, sidebar, toolbar. The canvas is a separate iframe document and does not inherit the parent’s stylesheets. The classic miss: the team enqueues Google Fonts on that hook, the editor UI gets the font, the canvas does not, and the ticket reads “fonts broken in editor” when the font was never pointed at the canvas at all.

One adjacent flag points the opposite direction and gets conflated with this one: add_theme_support( 'wp-block-styles' ) opts the front end into core’s default block styles, which the editor already loads by default. If your blocks look styled in the editor but naked on the site, you are missing that flag, not editor styles. Opposite symptom, opposite fix — the add_theme_support reference lists everything that one function gates.

Two developers comparing a stylesheet against the rendered page on a laptop
Verification in progress: the stylesheet on one side, the canvas on the other, and neither panel volunteering information.

The trace: three checks, in order

Run these in sequence; each eliminates a class of causes.

1. Confirm the contract at the CLI.

wp eval 'var_dump( current_theme_supports( "editor-styles" ) );'
bool(true)

wp eval 'global $editor_styles; print_r( $editor_styles );'
Array
(
    [0] => assets/css/editor-style.css
)

wp eval 'var_dump( file_exists( get_stylesheet_directory() . "/assets/css/editor-style.css" ) );'
bool(true)

If the flag returns false, the registration is being ignored — start in functions.php. If the array is empty, the delivery call never ran. If file_exists returns false, you have found your silent skip and the fix is a path.

2. Log the settings payload. Drop this into a must-use plugin, load the post editor, then read debug.log:

// wp-content/mu-plugins/editor-styles-trace.php
add_filter( 'block_editor_settings_all', function ( $settings ) {
    if ( empty( $settings['styles'] ) || ! is_array( $settings['styles'] ) ) {
        error_log( '[editor-styles] settings carried no styles array.' );
        return $settings;
    }
    foreach ( $settings['styles'] as $i => $entry ) {
        $css  = isset( $entry['css'] ) ? $entry['css'] : '';
        $type = isset( $entry['__unstableType'] ) ? $entry['__unstableType'] : 'unknown';
        error_log( sprintf(
            '[editor-styles] entry %d: type=%s, %d chars, starts: %s',
            $i, $type, strlen( $css ),
            substr( preg_replace( '/\s+/', ' ', $css ), 0, 60 )
        ) );
    }
    return $settings;
} );

The filter fires for every editor instance — post, widgets, site editor — so expect multiple batches; the post editor is the one you want. On a healthy install you will see entries for theme.json-derived styles and, when the file channel works, one carrying your CSS. If the logger prints the “no styles array” line, the settings snapshot happened before your registration — cause 3 below.

3. Inspect the canvas directly. In the post editor, open DevTools, expand the iframe named editor-canvas, and read its head:

post.php (parent document)
├─ ... editor chrome: toolbar, sidebar, list view ...
└─ iframe name="editor-canvas"
   └─ #document
      ├─ <head>
      │   ├─ <style> ... core block styles, theme.json output ...
      │   └─ <style> @import url("...fonts...") body { ... } </style>
      │        └─ your editor-style.css, inlined verbatim
      └─ <body> ... post content ...

Your stylesheet shows up as an inlined style element — its contents verbatim, @import and all — not as a link to a file. If it is not there, no amount of refreshing the parent document will help; the CSS never shipped.

When you need positive proof, use a marker rule. Add this to the top of editor-style.css and load the editor:

/* Temporary: delete once the canvas turns pink. */
p { outline: 2px solid #f0f; }

Every paragraph in the canvas grows a fuchsia outline or the channel is broken. If the outline appears, any remaining mismatch is CSS specificity — not delivery. That single rule has saved me more hours than any panel in Query Monitor.

Root causes, ranked by how often they actually happen

1. The flag without the call

The most common by a wide margin. Someone read that the theme should “declare editor style support,” added the flag, and stopped reading. Symptom: total absence of editor CSS, front end unaffected. Trace: the CLI trio returns flag true, array empty. Fix: add the delivery call. Nothing else in the stack will compensate for it.

2. The right call, the wrong path

Paths in add_editor_style() resolve against the active theme’s root, not against the file doing the registering. Two usual shapes: the stylesheet lives in a subfolder but was registered as though it sat at the root ('editor-style.css' instead of 'assets/css/editor-style.css'), or the file exists only in the parent theme while a child theme is active. Trace: the file_exists check returns false. Fix: correct the path, confirm the marker rule. Remember that this failure is invisible in every panel — core skips the file without a warning.

3. Registered too late to be picked up

On the post editor screen, the settings array — including the snapshot of $editor_styles — is assembled while the page is being built, before admin_enqueue_scripts fires. A registration sitting in an admin_enqueue_scripts callback, or anywhere later in the screen’s assembly, misses the snapshot and never ships. Symptom: the CLI trio passes, the settings logger shows no entry from your file. Fix: register from functions.php, on plugins_loaded, or on after_setup_theme. Anything before screen assembly is safe.

4. Expecting style.css to mirror itself

Editor styles are a separate cascade. Nothing in core loads your front-end stylesheet into the canvas; parity is something you build. The common shape: body font, link color, and content width defined only in style.css, so the canvas receives the theme.json parts and nothing else, and the ticket reads “editor looks almost right.” Two fixes, in order of preference. Move shared tokens into theme.json, where the declarative channel handles both contexts. Or register the same file as an editor style — add_editor_style( array( 'style.css', 'assets/css/editor-style.css' ) ) — accepting that rules aimed at site wrappers that do not exist inside the canvas will simply not match. Both are defensible; the first is easier to maintain.

5. Fonts delivered to the chrome, not the canvas

Fonts enqueued on enqueue_block_editor_assets style the parent document; the canvas never sees them. Fix: deliver the font through the file channel with an @import at the very top of editor-style.css — it must be the first rule in the file or browsers ignore it — and reference the family in the body rule. The extra request inside the iframe is acceptable for an editing surface; it is not your front-end performance budget.

Developer confirming canvas styles in a browser inspector at a desk
End state: the fuchsia outline appears, the marker rule gets deleted, the ticket gets closed with a diff attached.

A baseline setup you can defend in review

The registration, with the contract stated in comments:

add_action( 'after_setup_theme', function () {
    // 1. The opt-in. Without it, the block editor ignores step 2.
    add_theme_support( 'editor-styles' );

    // 2. The delivery. Paths are relative to the active theme root,
    //    not to the file making this call.
    add_editor_style( array( 'assets/css/editor-style.css' ) );
} );

A deliberately thin stylesheet that handles what the declarative channel cannot:

/* @import must be the first rule in the file, or browsers ignore it. */
@import url("https://fonts.googleapis.com/css2?family=Source+Sans+3:wght@400;700&display=swap");

body {
    font-family: "Source Sans 3", system-ui, sans-serif;
    font-size: 1.125rem;
    line-height: 1.7;
    color: #1a1a1a;
    max-width: 720px;
    margin: 0 auto;
    padding: 0 24px;
}

a {
    color: #b3441f;
    text-decoration-thickness: 2px;
    text-underline-offset: 3px;
}

blockquote {
    border-left: 4px solid #1a1a1a;
    margin-inline-start: 0;
    padding-inline-start: 1.25rem;
}

Two cautions worth stating in review. First, relative url() references inside an inlined editor style depend on base-URL handling that has shifted between versions; if your icons vanish in the canvas but load on the front end, write the URLs out absolutely. Second, a file channel that keeps growing is a sign theme.json is being underused — colors, spacing, and preset typography belong in the declarative channel, which serves both contexts from one source of truth.

Verification, end to end

Close the loop in this order: the CLI trio returns true, a one-entry array, and true. The settings logger prints an entry with a nonzero character count for your file. The marker rule paints the canvas fuchsia. Delete the marker, reload, confirm the outline is gone. Then re-run the trio after every theme update — renamed asset folders and reshuffled parent themes are the usual regressions, and the CLI check catches both in under a minute.

FAQ

Does add_theme_support( ‘editor-styles’ ) load any CSS by itself?

No. It writes an opt-in flag into the global $_wp_theme_features array and nothing else. CSS reaches the block editor through add_editor_style() registrations — resolved against the theme root, read server-side, and inlined into the canvas iframe — or through theme.json styles, which apply without the flag. The flag’s job is to make the block editor honor the file channel; the classic editor honors it with or without the flag.

Why doesn’t Query Monitor show my editor stylesheet?

Because editor styles never pass through the WP_Styles API that Query Monitor instruments. Local editor stylesheets are read server-side, shipped inside the block editor settings payload, and inlined into the iframe canvas as style elements. There is no enqueue to list and no HTTP request to observe. To verify delivery, inspect the iframe head in DevTools or log the block_editor_settings_all filter.

Do I still need editor-style.css if my theme has theme.json?

Less than you think, but not never. theme.json covers colors, spacing, and preset typography in both the editor and the front end. Keep a thin editor stylesheet for externally hosted fonts, for parity with front-end rules that live in style.css, and for selectors theme.json cannot express. If the file keeps growing, more of it probably belongs in theme.json.

Does the classic editor need the support flag too?

No. TinyMCE loads add_editor_style() registrations without it. The flag is a block-editor requirement, which is why themes that styled the classic editor correctly needed a one-line addition once the block editor became the default editing surface.

Where this column goes next

The natural follow-up is the other side of the ledger: which parts of a growing editor-style.css belong in theme.json’s styles tree, and how to migrate them without a week of visual regressions. Same format — symptom, trace, fix, verification. If your settings log printed a variant that did not match any cause ranked above, that log line is the fastest way to narrow it down; the entry type and character count alone usually identify which channel broke. Send it along and it may open the next column.

How to Audit wp_usermeta for Capability Bloat That Slows Every Admin Request

wp_usermeta is where WordPress keeps everything it knows about a user that doesn’t fit the wp_users columns: capabilities, session tokens, admin screen state, and whatever plugins decide to hang off each account. It’s the quieter sibling of wp_options — same meta_key/meta_value design, LONGTEXT values, no per-key schema — and it inherits the priming behavior that makes bloat expensive: the first get_user_meta() call for a user loads every row that user has, not just the key you asked for. Capabilities live here under {prefix}capabilities. Role definitions live one table over, in the {prefix}user_roles option. And WP_User stitches the two together on every request that carries a logged-in session — which is why this audit belongs in your runbook. An eleven-year-old install with one heavy editor account can make every wp-admin screen feel like it’s rendering over dial-up while anonymous visitors see nothing wrong at all.

Two developers reviewing database query timings on a desktop computer

The symptom: slow admin, fast front end

The signature is easy to miss because it hides behind “the admin is slow,” which everyone says about everything. The tells are more specific:

  • Anonymous front-end requests are fine. Logged-in requests are not, and the slowness follows the account, not the page.
  • The worst offender is usually the longest-serving account — the founding editor, the person who’s been there since the first theme change.
  • DevTools puts the lag in TTFB, not in assets. No slow scripts, no oversized images, just server time.
  • Every admin screen is equally mediocre. No single broken screen to blame.

Open Query Monitor on wp-admin/index.php as that user and sort the query list by time. You’re looking for one query that carries a suspicious amount of weight for something called “meta cache priming”:

Query Monitor — wp-admin/index.php, logged in as user 1
Total query time: 0.31 s (of 0.62 s page generation)

1. SELECT user_id, meta_key, meta_value
   FROM wp_usermeta
   WHERE user_id IN (1)
   ORDER BY umeta_id ASC
   Caller: update_meta_cache() → get_metadata_raw() → get_user_meta()
   Component: core   Rows: 214   Time: 0.0213 s

On a client’s install last spring, that query moved roughly 1.3 MB for the founding editor on every single admin request — 214 rows of accumulated session tokens, screen state, and plugin per-user arrays. Twenty-one milliseconds doesn’t sound like much until you notice that PHP also has to unserialize the payload, every value of it, and that the same tax applied to every user on every logged-in request. After pruning, the same user primed 46 rows / 88 KB and the query ran in under 2 ms. Same host, same PHP version, same plugins.

The trace: one query you pay on every logged-in request

Here’s the full path. The fix only makes sense once you’ve seen why the cost is structural rather than accidental:

  1. wp-settings.php builds the current user during bootstrap — after plugins are loaded, before init fires.
  2. wp_get_current_user() constructs a WP_User, whose init() calls for_site().
  3. for_site() sets $this->cap_key to get_blog_prefix( $site_id ) . 'capabilities' and calls get_role_caps().
  4. get_role_caps() reads the capabilities row — get_caps_data()get_user_meta( $this->ID, $this->cap_key, true ).
  5. get_metadata_raw() finds no user_meta:{id} cache entry and calls update_meta_cache( 'user', array( $id ) ).
  6. That runs the query above — every row for the user, all keys, no filter.
  7. Every value gets unserialized into the object cache. Later per-key reads are in-process and cheap. The damage was done in step 6.

The WP_User class reference and the update_meta_cache() source are worth reading once, end to end; the priming behavior is load-bearing for this whole audit. Two consequences fall out of it:

The cost is the row set, not the table. A 2 GB wp_usermeta table is only a disk problem. The per-request problem is the current user’s slice: total bytes fetched, plus PHP unserialization of values you mostly never asked for. Multisite makes it worse in a non-obvious way — wp_2_capabilities through wp_40_capabilities all ride along in the same priming query, because the query filters on user_id, not on key prefix.

Screens that touch many users multiply the payload. update_meta_cache() accepts a list of IDs. An admin screen that pulls per-user plugin meta for the 20 users in a list primes all 20 full row sets in one go. If your users.php or author-heavy views are slow, this is usually why.

The options twin: {prefix}user_roles

Capabilities have a second home. wp_roles() hydrates the global WP_Roles instance from the {prefix}user_roles option, which is autoloaded by default — so role definitions are read on every request, including anonymous front-end ones. A membership plugin that registers six roles with per-post-type capability grants can quietly push that option past 100 KB. I’ve seen one at 170 KB; nobody on that team could name the plugin that wrote it. So “capability bloat” lives in two places, and an audit that only checks wp_usermeta is half an audit.

What capability bloat actually looks like

The database description in the WordPress documentation will tell you the schema: umeta_id, user_id, meta_key (VARCHAR 191, indexed), meta_value (LONGTEXT). It will not tell you what accumulates. This table will. Every row listed as “in the priming payload” is fetched and unserialized on every request that user makes.

meta_key Written by Growth pattern In priming payload?
wp_capabilities Core, on role change Role stacking by membership and roles plugins; dead slugs after plugin removal Yes
wp_user_level Core, on role change None — one row per user Yes
wp_{N}_capabilities, wp_{N}_user_level Core (multisite) One pair per site the user has ever touched Yes
session_tokens Core, per login One entry per device or session; nothing sweeps it on a schedule Yes
wp_user-settings, wp_user-settings-time Core admin UI Slow, bounded Yes
closedpostboxes_*, metaboxhidden_*, manageedit-*columnshidden, screen_layout_* Core admin UI One row per screen per user; survives every theme and plugin removal Yes
Plugin per-user arrays (membership overrides, notification read-state, builder preferences) Plugins Unbounded — the usual heavyweight Yes
Rows with no matching wp_users entry Direct SQL user deletes, broken importers None No — but it inflates the table and every backup

One subtlety worth writing down: a dead role slug in wp_capabilities is not just noise. Core merges the caps array into allcaps after role caps, so a stale slug like s:12:"old_editor";b:1; becomes a “capability” named old_editor. Plugins that check current_user_can( 'old_editor' ) — a discouraged but surviving pattern — will pass. And if a new plugin ever registers a role with the same slug, every legacy holder regains it instantly. Stale slugs are a security question wearing a performance costume.

The audit: five queries and a few CLI commands

Run everything below read-only first. Replace wp_ with your actual prefix — wp db prefix if you don’t know it.

Step 0 — baseline and backup

wp db prefix
wp db size --tables
wp db export pre-usermeta-audit.sql

If you can run this on staging instead of production, do. The deletes come later; the census comes first.

Step 1 — key census

Which keys own the table, in bytes rather than row counts. A thousand 40-byte rows are irrelevant; forty 40 KB rows are your problem.

SELECT meta_key,
       COUNT(*) AS row_count,
       SUM(LENGTH(meta_value)) AS total_bytes,
       ROUND(AVG(LENGTH(meta_value))) AS avg_bytes
FROM wp_usermeta
GROUP BY meta_key
ORDER BY total_bytes DESC
LIMIT 25;

Read the output top-down and ask one question per key: does a current, active writer own this key? If nothing on the install claims it, it’s a candidate for removal — after Step 2 confirms who’s carrying it.

Step 2 — heaviest users

SELECT u.ID, u.user_login,
       COUNT(um.umeta_id) AS row_count,
       SUM(LENGTH(um.meta_value)) AS total_bytes
FROM wp_users u
JOIN wp_usermeta um ON um.user_id = u.ID
GROUP BY u.ID, u.user_login
ORDER BY total_bytes DESC
LIMIT 15;

If your slow-admin complaint has a name attached to it, this query usually finds that name at the top. The founding editor with 214 rows isn’t a coincidence; row count tracks account age almost perfectly, because screen-state keys and plugin arrays accrue and nothing retires them.

Step 3 — role census and dead slugs

SELECT meta_value, COUNT(*) AS user_count
FROM wp_usermeta
WHERE meta_key = 'wp_capabilities'
GROUP BY meta_value
ORDER BY user_count DESC;

Grouping on the serialized value works cleanly for single-role users and fragments for multi-role users, so treat it as a quick census, not gospel. Then list the roles that actually exist:

wp role list --format=csv
wp user meta get 1 wp_capabilities

Compare the slugs in the census against wp role list. Anything present in usermeta but absent from the roles option is a dead slug: residue from a removed plugin, a membership system you migrated off, or a role someone deleted via wp role delete without cleaning up the users. The prune script in the fixes section handles the comparison for you.

Step 4 — orphaned rows

SELECT COUNT(*) AS row_count,
       IFNULL(SUM(LENGTH(um.meta_value)), 0) AS total_bytes
FROM wp_usermeta um
LEFT JOIN wp_users u ON u.ID = um.user_id
WHERE u.ID IS NULL;

Zero is the correct answer. Anything else means someone deleted users with raw SQL or a broken importer, because wp_delete_user() cleans meta properly. Orphans don’t slow requests — nothing reads them — but they bloat the table, the backups, and any future migration, and they make this audit’s numbers lie.

Step 5 — the autoloaded roles option

SELECT option_name, LENGTH(option_value) AS total_bytes, autoload
FROM wp_options
WHERE option_name LIKE '%user_roles'
ORDER BY total_bytes DESC;

SELECT option_name, LENGTH(option_value) AS total_bytes
FROM wp_options
WHERE autoload = 'yes'
ORDER BY total_bytes DESC
LIMIT 20;

Anything over roughly 100 KB in an autoloaded option deserves scrutiny, and {prefix}user_roles is the one capability-adjacent value that taxes anonymous traffic too.

Editorial team auditing a WordPress database together on laptops

The fixes

Strip dead role slugs

remove_role() and wp role delete edit only the {prefix}user_roles option. The users keep the slug in their caps array, indefinitely. Strip it deliberately, with a dry run first:

<?php
/**
 * prune-dead-roles.php — strips role slugs from {prefix}capabilities
 * that no longer exist in {prefix}user_roles.
 * Usage: wp eval-file prune-dead-roles.php
 * DRY_RUN defaults to true. Flip to false only after reviewing the log.
 */
global $wpdb;

define( 'PRUNE_DRY_RUN', true );

$cap_key = $wpdb->get_blog_prefix() . 'capabilities';
$live    = array_keys( wp_roles()->get_names() );

// 1. Census every slug present in capabilities rows.
$seen = array();
$rows = $wpdb->get_col(
    $wpdb->prepare(
        "SELECT DISTINCT meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s",
        $cap_key
    )
);
foreach ( $rows as $serialized ) {
    $caps = maybe_unserialize( $serialized );
    if ( ! is_array( $caps ) ) {
        continue;
    }
    foreach ( array_keys( $caps ) as $slug ) {
        $seen[ $slug ] = ( $seen[ $slug ] ?? 0 ) + 1;
    }
}

$dead = array_diff( array_keys( $seen ), $live );
if ( array() === $dead ) {
    WP_CLI::log( 'No dead role slugs. Nothing to do.' );
    return;
}
WP_CLI::log( 'Dead slugs: ' . implode( ', ', $dead ) );

// 2. Strip them per user, and only those keys.
foreach ( get_users( array( 'fields' => 'ID' ) ) as $user_id ) {
    $caps = get_user_meta( $user_id, $cap_key, true );
    if ( ! is_array( $caps ) ) {
        continue;
    }
    $pruned = array_diff_key( $caps, array_fill_keys( $dead, true ) );
    if ( $pruned === $caps ) {
        continue;
    }
    if ( PRUNE_DRY_RUN ) {
        WP_CLI::log( sprintf(
            'DRY user=%d would drop: %s',
            $user_id,
            implode( ', ', array_keys( array_diff_key( $caps, $pruned ) ) )
        ) );
    } else {
        update_user_meta( $user_id, $cap_key, $pruned );
        clean_user_cache( $user_id );
    }
}
WP_CLI::log( PRUNE_DRY_RUN ? 'Dry run complete. No rows written.' : 'Done.' );

The script removes only keys that are provably dead — slugs absent from the roles option — so legacy individual capabilities stored in the same array survive. On multisite, run it once per site with --url=, since the cap key is per-site.

Delete orphaned rows

After the backup from Step 0, and after confirming the count with Step 4:

DELETE um FROM wp_usermeta um
LEFT JOIN wp_users u ON u.ID = um.user_id
WHERE u.ID IS NULL;

This is the one query in the article that cannot break a live request, because no live user owns those rows. It can break your rollback, though, if you skipped the export. Don’t skip the export.

Compact session tokens and shorten cookie life

Nothing in core sweeps session_tokens on a schedule; the row gets rewritten when sessions are created or destroyed, not before. For heavy accounts:

wp eval 'WP_Session_Tokens::get_instance( 42 )->destroy_all();'

That logs user 42 out everywhere — tell them first. To slow re-accumulation, tighten the remembered-cookie window:

add_filter( 'auth_cookie_expiration', function ( $expires, $user_id, $remember ) {
    return $remember ? WEEK_IN_SECONDS : DAY_IN_SECONDS * 2;
}, 10, 3 );

Default is fourteen days remembered, two days not. The tradeoff is real: editors on shared machines will grumble about logging in weekly. That grumble is cheaper than a 60 KB session row riding every request.

Retire stale screen-state keys

DELETE FROM wp_usermeta WHERE meta_key LIKE 'closedpostboxes_%';
DELETE FROM wp_usermeta WHERE meta_key LIKE 'metaboxhidden_%';
DELETE FROM wp_usermeta WHERE meta_key LIKE 'manageedit-%columnshidden';

Yes, _ is a wildcard in LIKE; for these prefixes it happens to match exactly the keys you mean. The rows regenerate as users rearrange their screens — good news, nothing is lost permanently; bad news, your editors will re-collapse the same boxes and one of them will file a ticket about it. Run these deletes when the rows reference screens that no longer exist (post types you deleted, plugins you removed), not on a schedule.

On multisite, delete per-site capability pairs only for sites that no longer exist. Enumerate live IDs with wp site list --field=id, then remove the specific dead keys (wp_3_capabilities, wp_3_user_level, and so on) by name. Don’t pattern-match your way through this one.

What not to delete

  • wp_user_level — legacy plugins and older themes still read it, and core rewrites it on the next role change anyway. Deleting it is pointless busywork.
  • Live wp_capabilities values — the audit’s job is to slim these, not remove them.
  • session_tokens for users mid-session, unless destroying their sessions is the point.
  • Plugin keys whose writers are still installed. The Step 1 census tells you which keys are heavy; the plugins list tells you whether the writer still exists. Delete only when both answers line up.

Verification: measure it twice

Re-run the per-user metric for every account you touched:

wp db query "SELECT COUNT(*) AS row_count, SUM(LENGTH(meta_value)) AS total_bytes FROM wp_usermeta WHERE user_id = 1;"

Then compare Query Monitor captures on the same screen, same user, before and after. The client install from the opening section, for the record:

Before: priming query 214 rows / 1.3 MB / 0.0213 s
        total query time 0.31 s, page generation 0.62 s
After:  priming query  46 rows /  88 KB / 0.0018 s
        total query time 0.09 s, page generation 0.34 s

Numbers, not adjectives. Then verify capability behavior, because a cleanup that breaks logins is not a cleanup:

wp eval 'wp_set_current_user( 7 ); var_dump( current_user_can( "edit_posts" ) );'
wp cap list editor | head -3
wp eval 'var_dump( array_keys( get_userdata( 7 )->caps ) );'

Log in as one pruned user from each affected role and click through the admin. If anything regressed, the dry-run log from the prune script tells you exactly which keys were dropped from which accounts, and the export from Step 0 puts them back.

Developer at a desk comparing before and after query measurements

Keeping the table honest

Three habits keep the bloat from coming back:

  • A deactivation checklist. When you remove a plugin that registered roles, run wp role delete <slug>, then run the prune script the same day. Residue never gets the chance to age into “mystery data.”
  • A census after every uninstall. Re-run the Step 1 query and diff it against the last census. Plugin per-user arrays are the heaviest category in the table above, and uninstalls are when they turn into orphans.
  • Know what a persistent object cache does and doesn’t do. Redis or Memcached removes the SQL round trip after the first prime, but the cached payload still crosses the process boundary and unserializes on every request — and any update_user_meta() invalidates the whole user_meta:{id} entry, so frequently written keys keep the hit rate poor. A cache hides the symptom. It does not shrink the row set.

If you’re building this discipline on a fresh install, the triage order in what to fix first when a new WordPress site says nothing found is the day-one companion — capability bloat is a three-year problem, and it’s cheaper never to accrue it than to audit it. This piece is the second entry in a series on table-level failure modes; the next one applies the same priming analysis to wp_postmeta, where the payload math gets considerably worse.

FAQ

Does a large wp_usermeta table slow the site for anonymous visitors?

No. Meta priming is per logged-in user; an anonymous request builds a WP_User with ID 0 and never queries wp_usermeta for it. The one exception is the {prefix}user_roles option, which is autoloaded and therefore read on every request, anonymous ones included. Table size alone is a disk and backup cost, not a per-request one — the current user’s row set is the per-request cost.

Is it safe to delete the wp_user_level meta rows?

No, and it’s pointless anyway. wp_user_level is maintained by core on every role change for backward compatibility, and legacy plugins and older themes still read it. Delete it and the next role update recreates it. Leave it alone; it’s one small row per user.

Will a persistent object cache fix capability bloat?

It masks the SQL but not the payload. With Redis or Memcached, the priming query runs once and the result is served from cache — but the full meta array still crosses the wire and gets unserialized on every request, and any usermeta write invalidates the entire cached entry for that user. Treat a cache as latency relief, then prune the row set anyway.

How much usermeta is too much for one user?

A working editor on a healthy install carries tens of rows and well under 100 KB. Hundreds of rows, or megabytes, means something is writing per-user data without a retirement plan — usually a plugin storing read-state or preference arrays. The number that matters for performance is the heaviest daily user’s total bytes, which Step 2 of the audit measures directly.

Why does removing a role leave data in wp_usermeta?

remove_role() — and its CLI wrapper wp role delete — edits only the {prefix}user_roles option. The users’ {prefix}capabilities rows keep the slug forever, because core has no cleanup path for role-to-user assignments. That residue is harmless until a role with the same slug gets registered again, at which point every legacy holder regains it. Hence the prune script.

Why register_taxonomy() Slugs Collide With Page Slugs Months Later (And How to Trace the Rewrite Conflict)

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:

  1. Per-post-type rules (feeds, trackbacks, embeds, comments)
  2. Per-taxonomy rules (term archives, feeds)
  3. Date archive rules
  4. Search rules
  5. Pagination rules
  6. Root-level rules (home, front page)
  7. 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 every register_taxonomy() call, with the URL pattern it generates.
  • Post type slugs: The rewrite['slug'] value for every register_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.

Why Your Block Styles Enqueue in the Editor But Not the Frontend (And the enqueue_block_assets Hook Order)

Block styles that show up fine in the editor and then disappear on the live site are usually a hook-order problem. The main entity here is enqueue_block_assets, a hook that fires in both the editor and the frontend, but with different timing and context than enqueue_block_editor_assets. Adjacent concepts include wp_enqueue_scripts, admin_enqueue_scripts, should_load_separate_core_block_assets, and the block_assets registration path in WP_Block_Type_Registry. For small-to-mid publishing teams that maintain their own production installs, this failure mode usually means a stylesheet is registered in the editor context but never enqueued for site visitors, or it is enqueued too early and then dequeued by a later dependency check. The result is a visual mismatch between what editors approve and what readers see.

This article walks through the exact hook order, reproduces the failure with a minimal plugin, and shows how to inspect the enqueue chain with WP-CLI and SQL. No theory-only advice. Every claim is tied to a snippet you can run on a staging install.

What enqueue_block_assets Actually Does

enqueue_block_assets is documented as firing when block assets are enqueued for both the editor and the frontend. In core, the hook is triggered inside wp_common_block_scripts_and_styles(), which runs on the wp_enqueue_scripts action for the frontend and on enqueue_block_editor_assets for the editor. That dual context is the source of most confusion.

If you register a style with wp_enqueue_style() directly on enqueue_block_assets, it will load in both contexts. But if you wrap the call in an is_admin() check, or if you use wp_register_style() on enqueue_block_assets and then enqueue it only inside an editor-specific callback, the frontend never sees it. The opposite failure also happens: a style is enqueued on enqueue_block_assets but a later wp_dequeue_style() call on wp_enqueue_scripts removes it before the page renders.

The Hook Order in a Default Theme

On a standard frontend request with a block theme, the relevant order is:

  1. wp_enqueue_scripts fires.
  2. Core calls wp_common_block_scripts_and_styles() on that action.
  3. Inside that function, enqueue_block_assets fires.
  4. Registered block styles from WP_Block_Type_Registry are enqueued.
  5. Theme and plugin styles enqueued on wp_enqueue_scripts with a later priority are added.
  6. WordPress prints the styles in the wp_head output.

In the editor, the order is different. The enqueue_block_editor_assets action fires after the editor script is loaded, and wp_common_block_scripts_and_styles() is called again, which triggers enqueue_block_assets a second time. That means a callback on enqueue_block_assets can run twice on an editor screen: once for the editor context and once for the frontend context if the editor page also loads frontend assets for previews.

Reproducing the Failure with a Minimal Plugin

Create a plugin with this code:

add_action( 'enqueue_block_assets', function () {
    wp_register_style(
        'jvs-editor-only-style',
        plugin_dir_url( __FILE__ ) . 'editor-only.css',
        [],
        '1.0.0'
    );
} );

add_action( 'enqueue_block_editor_assets', function () {
    wp_enqueue_style( 'jvs-editor-only-style' );
} );

In the block editor, the style loads because enqueue_block_editor_assets runs after enqueue_block_assets has registered the handle. On the frontend, the style never loads because nothing enqueues the handle after registration. The editor shows the styled block; the published page does not.

Now reverse the pattern:

add_action( 'enqueue_block_assets', function () {
    wp_enqueue_style(
        'jvs-frontend-style',
        plugin_dir_url( __FILE__ ) . 'frontend.css',
        [],
        '1.0.0'
    );
} );

add_action( 'wp_enqueue_scripts', function () {
    wp_dequeue_style( 'jvs-frontend-style' );
}, 20 );

Here the style is enqueued on enqueue_block_assets, but the later wp_enqueue_scripts callback with priority 20 dequeues it. The editor still shows the style because the dequeue callback does not run in the editor context. This is a common pattern when a developer tries to conditionally remove a style for certain templates but accidentally removes it everywhere on the frontend.

Inspecting the Enqueue Chain with WP-CLI

To see which styles are registered and enqueued on a given page, use WP-CLI with a small must-use plugin that dumps the global wp_styles object at the wp_footer action:

add_action( 'wp_footer', function () {
    global $wp_styles;
    if ( defined( 'WP_CLI' ) && WP_CLI ) {
        WP_CLI::log( 'Registered styles:' );
        foreach ( $wp_styles->registered as $handle => $style ) {
            WP_CLI::log( $handle . ' => ' . $style->src );
        }
        WP_CLI::log( 'Enqueued styles:' );
        foreach ( $wp_styles->queue as $handle ) {
            WP_CLI::log( $handle );
        }
    }
}, 99 );

Run wp eval-file dump-styles.php on a frontend URL and compare the output with the editor screen. The difference between the two lists is your missing stylesheet.

For a database-level check, query the postmeta table for block style metadata that might be stored per post:

SELECT post_id, meta_key, meta_value
FROM wp_postmeta
WHERE meta_key LIKE '%_wp_block_styles%'
ORDER BY post_id DESC
LIMIT 20;

This is useful when a block style is applied only to a specific post in the editor but the frontend render does not include the style because the block type is not registered on the frontend.

Why the Frontend Render Path Is Different

The block editor uses the WP_Block_Type object to render previews, and that object includes the style and editor_style properties. On the frontend, the render path goes through render_block(), which does not automatically enqueue block styles. Core only enqueues block styles on the frontend if the block is present in the post content and the should_load_separate_core_block_assets filter returns true. For custom blocks, the developer must enqueue the style manually on enqueue_block_assets or wp_enqueue_scripts.

This is the second most common cause: a custom block registers its style with editor_style in block.json, which loads only in the editor. The style property is supposed to load on both, but if the block is registered with register_block_type() and the style handle is not enqueued on the frontend, the style never appears. The fix is to add a separate wp_enqueue_style() call on enqueue_block_assets for the frontend handle.

Checking block.json Registration

Run this WP-CLI command to see how a block type is registered:

wp eval 'print_r( WP_Block_Type_Registry::get_instance()->get_registered( "namespace/block-name" ) );'

Look for the style and editor_style properties. If style is missing or points to a handle that is never enqueued, that is your frontend gap.

Fixing the Hook Order Without Breaking the Editor

The reliable pattern is to enqueue frontend styles on enqueue_block_assets and editor-only styles on enqueue_block_editor_assets. Do not use is_admin() inside enqueue_block_assets to decide whether to enqueue a style, because the hook fires in both contexts and the check will be true in the editor and false on the frontend, which is exactly the bug you are trying to avoid.

If you need a style on both, use a single callback on enqueue_block_assets with no context check:

add_action( 'enqueue_block_assets', function () {
    wp_enqueue_style(
        'jvs-both-contexts',
        plugin_dir_url( __FILE__ ) . 'both.css',
        [],
        '1.0.0'
    );
} );

If you need a style only in the editor, use enqueue_block_editor_assets. If you need a style only on the frontend, use wp_enqueue_scripts with a priority after enqueue_block_assets has fired, or use enqueue_block_assets and then conditionally dequeue on wp_enqueue_scripts only for the specific templates where you do not want it.

When the Theme Is the Culprit

Block themes sometimes enqueue styles on after_setup_theme or wp_enqueue_scripts with a priority that runs before enqueue_block_assets. If the theme registers a style handle that a block also uses, the block’s enqueue call may be ignored because the handle is already registered with a different source. Check the theme’s functions.php for wp_register_style() calls that use the same handle as your block style.

Use this WP-CLI command to list all registered style handles and their sources on a frontend page:

wp eval 'global $wp_styles; foreach ( $wp_styles->registered as $handle => $style ) { echo $handle . " => " . $style->src . "\n"; }'

If your block style handle appears with a theme URL instead of your plugin URL, the theme is overriding it. Rename your handle or enqueue with a higher priority.

FAQ

Why does my block style load in the editor but not on the frontend?

Most likely the style is registered on enqueue_block_assets but only enqueued on enqueue_block_editor_assets, or the block’s block.json uses editor_style instead of style. Check the enqueue chain with WP-CLI and compare the editor and frontend style queues.

Can I use is_admin() inside enqueue_block_assets to conditionally load styles?

No. enqueue_block_assets fires in both the editor and the frontend. Using is_admin() inside that hook will return true in the editor and false on the frontend, which recreates the exact bug. Use separate hooks for editor-only and frontend-only styles.

How do I check if a block style is registered but not enqueued?

Dump the global wp_styles object at wp_footer with a must-use plugin and WP-CLI. Compare the registered array with the queue array. If your handle is in registered but not in queue, it is registered but never enqueued on that page.

What is the correct hook for a style that should load on both the editor and the frontend?

Use enqueue_block_assets with a single wp_enqueue_style() call and no context check. That hook fires in both contexts, so the style will be enqueued for both.

For a related failure mode where a new WordPress site returns nothing on archive pages, see What to Fix First When a New WordPress Site Says Nothing Found.

WordPress block editor showing a style panel with a missing frontend stylesheet
Code editor with a PHP snippet for enqueue_block_assets hook order
WP-CLI terminal output comparing editor and frontend enqueued styles

How to Reconstruct a Broken Shortcode’s Original Intent From Post Content Alone

Shortcodes are WordPress’s native macro system: bracketed tokens like that expand into server-rendered output at runtime. When a shortcode breaks, the failure usually appears as raw bracket text in the front end, a blank section where a form or gallery should be, or a fatal error during do_shortcode(). For small-to-mid publishing teams that maintain their own production installs, the practical problem is not just fixing the renderer. It is recovering what the shortcode was supposed to do when the plugin is gone, the callback is missing, or the original attributes were never documented. This article shows how to reconstruct that intent from post content alone, using reproducible SQL, WP-CLI, and block-editor inspection techniques.

This matters because shortcode failures are often treated as plugin problems, but the real damage is editorial. A broken [pullquote] or [chart] leaves a hole in an article that may have been live for years. If you cannot recover the original intent, you either delete the token and lose the embedded meaning, or you guess and risk changing the article’s structure. The methods below are evidence-driven: they start with the stored post content, not with assumptions about the plugin that created it.

Start With the Stored Post Content, Not the Rendered Page

The first step is to inspect the raw post_content field. The rendered page may hide the shortcode behind a blank div, a cached fragment, or a fatal error. The database row is the source of truth.

SELECT ID, post_title, post_status, post_content
FROM wp_posts
WHERE post_content LIKE '%[%' 
  AND post_status = 'publish'
ORDER BY post_modified DESC
LIMIT 50;

This query returns every published post that contains at least one opening square bracket. It is intentionally broad because broken shortcodes are not always obvious. A shortcode can be nested inside a block comment, a custom HTML block, or a classic editor paragraph. The raw content shows the exact token, its attributes, and any surrounding text that hints at its purpose.

If you prefer WP-CLI, the equivalent is:

wp post list --post_type=post --post_status=publish --fields=ID,post_title,post_content --format=json | grep -B2 -A2 '\['

This is slower on large databases but useful when you need to pipe results into a file for diffing against a backup.

Identify the Shortcode Signature and Its Attribute Shape

Once you have the raw token, record its exact signature. A shortcode like [product id="42" sku="ABC-123"] tells you three things: the tag name, the attribute keys, and the attribute values. The tag name is the strongest clue. It usually matches the plugin slug, the developer’s namespace, or a feature name.

For example, is a core shortcode. [contact-form-7] is plugin-specific. [et_pb_section] is Divi. [vc_row] is WPBakery. If the tag is custom, search the active theme and plugin directories for the string add_shortcode(:

grep -R "add_shortcode" wp-content/plugins wp-content/themes

If the callback is missing, the shortcode will not render. But the attribute shape still tells you what the original author intended. A shortcode with ids="12,34,56" was almost certainly pulling specific posts or attachments. A shortcode with category="news" count="5" was a query loop. A shortcode with title="" class="" was probably a wrapper for styling.

Recover Attribute Semantics From Adjacent Content

Look at the text immediately before and after the shortcode in post_content. Authors often write a lead-in sentence that explains what the shortcode should show. For example:

Here are the top five posts from the archives:
[top_posts count="5" category="archives"]

The lead-in gives you the semantic intent: a list of five posts from the archives category. Even if the shortcode callback is gone, you can replace it with a core block or a simple WP_Query loop that matches that intent.

If the shortcode is self-closing and has no adjacent text, check the post’s revision history. The original version may have included a plain-text placeholder before the shortcode was inserted.

SELECT wp_posts.ID, wp_posts.post_title, wp_posts.post_content
FROM wp_posts
WHERE wp_posts.post_type = 'revision'
  AND wp_posts.post_parent = 123
ORDER BY wp_posts.post_date ASC;

Replace 123 with the post ID. Revisions often preserve the pre-shortcode text, which can reveal the author’s original wording.

Map the Shortcode to a Known Plugin or Core Feature

WordPress core ships with a small set of shortcodes: , , , , , and . If the broken token matches one of these, the fix is usually a theme or plugin conflict, not a missing callback. The Shortcode API documentation lists the core tags and their default attributes.

For plugin-specific shortcodes, the plugin’s readme or source code is the best reference. If the plugin is still installed but deactivated, reactivate it temporarily and inspect the rendered output. If the plugin was deleted, check the WordPress.org plugin repository or the developer’s documentation. The tag name is often enough to find the original plugin.

If the shortcode is from a commercial theme or page builder, the attribute names are usually documented in the theme’s help files. For example, a broken [et_pb_section] token with background_color="#f5f5f5" and padding_top="20px" tells you it was a full-width section with a light gray background and 20 pixels of top padding. That is enough to rebuild the section as a group block with the same spacing and background.

Reconstruct the Intent From Attribute Values Alone

When the tag name is unknown and the plugin is gone, the attribute values are your only evidence. Treat them as a data contract. Each key-value pair is a constraint on what the shortcode was supposed to do.

Here is a practical example. A post contains this token:

[display_posts items="3" type="portfolio" order="date" direction="desc"]

The tag display_posts is generic, but the attributes are specific. items="3" means it displayed three items. type="portfolio" means it filtered by a custom post type or taxonomy called portfolio. order="date" and direction="desc" mean it sorted by date, newest first. The reconstruction is a query loop that pulls the three most recent portfolio items. You can implement that with a core Query Loop block or a small custom shortcode that matches the original attribute names.

This approach works because shortcode authors tend to use attribute names that mirror WordPress query parameters. posts_per_page, category, tag, orderby, and order are common. If the attribute names are cryptic, check the post’s other shortcodes. The same author may have used the same plugin elsewhere with more descriptive attributes.

Use the Block Editor as a Reconstruction Sandbox

Once you have a hypothesis about the shortcode’s intent, test it in the block editor before touching the live post. Create a new draft, add a shortcode block with the original token, and preview the output. If the shortcode is still registered, you will see the rendered result. If it is broken, the block will show the raw token, which confirms the failure.

Then replace the shortcode block with the equivalent core blocks. For a gallery shortcode, use the Gallery block. For a query loop, use the Query Loop block. For a pullquote, use the Pullquote block. The block editor’s block markup is stored as HTML comments in post_content, so you can compare the before and after versions with a diff tool.

This sandbox approach is safer than editing the live post directly. It also gives you a visual check: if the reconstructed blocks look wrong, your attribute interpretation was probably wrong.

Query the Database for Shortcode Usage Patterns

If the same broken shortcode appears in multiple posts, aggregate the attribute values to find the most common configuration. This is especially useful for shortcodes that were used as templates, like a call-to-action box or a related-posts widget.

SELECT
  SUBSTRING_INDEX(SUBSTRING_INDEX(post_content, '[', -1), ']', 1) AS shortcode_token,
  COUNT(*) AS usage_count
FROM wp_posts
WHERE post_content LIKE '%[%'
  AND post_status = 'publish'
GROUP BY shortcode_token
ORDER BY usage_count DESC
LIMIT 20;

This query is crude because it only captures the first shortcode in each post, but it gives you a frequency map. For a more precise extraction, use a script that parses all shortcode tokens with a regular expression. The pattern /\[(\w+)([^\]]*)\]/ matches the tag name and attribute string for most shortcodes.

Once you have the frequency map, focus on the most common token. That is the shortcode that caused the most editorial damage. Reconstruct its intent first, then apply the same fix to all affected posts with a SQL update or a WP-CLI search-replace.

Reconstructing a Shortcode That Wrapped Content

Enclosing shortcodes are harder to reconstruct because the wrapped content is part of the intent. A token like [note]This is important.[/note] tells you two things: the shortcode wrapped a piece of text, and the text itself is the content. The reconstruction is a styled block that preserves the text.

In the block editor, the equivalent is a Group block with a custom class, or a Paragraph block with a background color. The key is to preserve the wrapped text exactly. Do not paraphrase or summarize it. The original author chose those words for a reason.

If the enclosing shortcode had attributes, they usually control the wrapper’s appearance. [note color="red"] means the note should have a red border or background. [box title="Warning"] means the box should have a visible title. Reconstruct those visual cues with block styles or a small CSS class.

When the Shortcode Is a Data Source, Not a Renderer

Some shortcodes are not visual. They pull data from an external API, a custom table, or a transient. A broken [stock_price symbol="AAPL"] token is not a styling problem. It is a data pipeline problem. The reconstruction is not a block replacement; it is a decision about whether the data is still available and whether the article still needs it.

In these cases, the attribute values are the data contract. symbol="AAPL" means the shortcode was fetching the current price of Apple stock. If the data source is gone, the article has a factual hole. You have two options: remove the token and add a plain-text note, or replace it with a static value that was correct at the time of publication. The second option is better for archival integrity, but it requires a source for the historical value.

Check the post’s revision history for a cached version of the rendered output. If the shortcode was rendered before it broke, the revision may contain the final HTML. That HTML is the most accurate reconstruction you can get.

SELECT post_content
FROM wp_posts
WHERE post_type = 'revision'
  AND post_parent = 123
  AND post_content LIKE '%stock_price%'
ORDER BY post_date DESC
LIMIT 1;

If the revision contains the rendered HTML, copy it into the live post as a custom HTML block. That preserves the original output without depending on the broken shortcode.

Document the Reconstruction for Future Editors

After you reconstruct a shortcode’s intent, document it. A shortcode that broke once will break again if the same plugin is removed or the same theme is changed. The documentation should live in the post itself, not in a separate wiki. A simple HTML comment at the top of the post content is enough:

<!-- Reconstructed from [display_posts items="3" type="portfolio"] on 2025-01-15. Original plugin: Portfolio Display. Replacement: Query Loop block. -->

This comment is invisible to readers but visible to anyone who edits the post in the code editor. It records the original token, the reconstruction date, and the replacement method. That is the kind of durable documentation that prevents the same failure from happening twice.

For a broader fix, create a site-specific plugin that registers the missing shortcode with a simple fallback. If the original plugin is gone, the fallback can render a plain-text version of the shortcode’s attributes. That keeps the post content intact while you work on a permanent replacement.

add_shortcode( 'display_posts', function( $atts ) {
    $atts = shortcode_atts( array(
        'items' => 3,
        'type'  => 'portfolio',
    ), $atts );
    return sprintf( '<!-- Reconstructed display_posts shortcode: %s -->', esc_html( wp_json_encode( $atts ) ) );
} );

This fallback does not render the original output, but it preserves the attribute data in the HTML source. That is enough for a future editor to understand what the shortcode was supposed to do.

Common Failure Modes and Their Reconstructions

Here are three real failure modes I have seen in production installs, with the reconstruction method for each.

1. Plugin Deactivated, Shortcode Left Behind

A site used a plugin called related-posts-widget that registered [related_posts]. The plugin was deactivated during a cleanup, and every post that used the shortcode started showing raw bracket text. The fix was to query all posts with [related_posts], extract the count and category attributes, and replace the token with a core Query Loop block that matched the same parameters. The reconstruction took about an hour for 40 posts.

2. Theme Change Removed a Page Builder Shortcode

A site moved from a commercial theme to a block theme. The old theme used [section] shortcodes for layout. The new theme ignored them, leaving blank spaces in the content. The reconstruction involved parsing each [section] token’s attributes, mapping them to Group block spacing and background settings, and rebuilding the layout in the block editor. The attribute names were the key: padding, background, and columns mapped directly to block settings.

3. Shortcode Callback Fatal Error

A custom shortcode [chart] called a PHP function that used a deprecated API. The function threw a fatal error, which broke the entire page. The fix was to remove the shortcode callback and replace the token with a static image of the chart. The image was recovered from the site’s media library, where the original chart had been uploaded as an attachment. The post content was updated with a core Image block pointing to that attachment.

FAQ

How do I find all posts that contain a specific broken shortcode?

Use a SQL query with a LIKE pattern that matches the shortcode tag. For example, to find all posts with [display_posts, run:

SELECT ID, post_title, post_status
FROM wp_posts
WHERE post_content LIKE '%[display_posts%'
  AND post_status = 'publish';

This returns every published post that contains the token, including posts where the shortcode is nested inside other content. For a more precise match, include the closing bracket in the pattern: '%[display_posts %' or '%[display_posts]%'.

Can I recover the rendered output of a broken shortcode from a backup?

Yes, if the backup was taken before the shortcode broke. Restore the backup to a staging environment, then view the affected post. The rendered output is the most accurate reconstruction you can get. Copy the HTML into the live post as a custom HTML block. If you do not have a full backup, check the post’s revision history. Revisions sometimes contain the rendered output from before the shortcode was broken.

What is the safest way to replace a broken shortcode in a live post?

Create a draft copy of the post, make the replacement in the draft, and preview it before publishing. This gives you a side-by-side comparison of the old and new content. If the replacement looks wrong, discard the draft and try a different interpretation of the shortcode’s attributes. Never edit the live post directly when you are unsure about the shortcode’s intent.

How do I prevent shortcode breakage in the future?

Document every shortcode your site uses, including its tag name, attributes, and the plugin or theme that registers it. Store this documentation in a site-specific plugin or a private page on the site. When you deactivate a plugin or change themes, run a search for its shortcodes before making the change. The query in the first section of this article is a good starting point.

For more on diagnosing WordPress content problems, see What to Fix First When a New WordPress Site Says Nothing Found.

Person examining code on a laptop screen while reconstructing a broken WordPress shortcode

Close-up of database query results showing post content with shortcode tokens

Editor comparing raw post content with rendered output in WordPress admin

The Specific Way WordPress Transient Keys Collide Across Multisite Blogs and How to Namespace Them

WordPress transients are the key-value cache layer that stores expensive query results, remote API responses, and computed fragments in the options table or an external object cache. In a multisite network, the same transient key can resolve to different values on different blogs, or worse, the same value can leak across blogs because the key is not namespaced per site. This article documents the exact collision mechanics, shows reproducible SQL and WP-CLI evidence, and gives a namespacing pattern that works in both single-site and multisite installs.

If you maintain a production multisite install for a small-to-mid publishing team, you have probably seen a transient from blog 2 appear in blog 3 after a cache flush, or a scheduled event fire with the wrong site context. The root cause is rarely the object cache backend. It is the key construction. WordPress core does not automatically prefix transient keys with the current blog ID in all contexts, and plugins that call set_transient() or get_transient() without a site-aware prefix inherit that behavior.

How WordPress Stores Transients in the Database

When no persistent object cache is active, WordPress stores transients in the wp_options table. The option name is built by prepending _transient_ or _transient_timeout_ to the key you pass. For example, set_transient( 'weather_london', $data, 600 ) writes two rows:

  • _transient_weather_london — the serialized value
  • _transient_timeout_weather_london — the Unix timestamp when the transient expires

In a multisite network, each blog has its own wp_2_options, wp_3_options, and so on. That physical separation prevents most cross-blog collisions at the database level. The problem appears when a plugin or theme uses a global cache group, a shared object cache, or a network-wide transient function without a blog-specific key.

Reproducing the Collision with SQL

Run this query on a multisite install with at least two blogs:

SELECT option_name, option_value
FROM wp_2_options
WHERE option_name LIKE '%weather_london%';

SELECT option_name, option_value
FROM wp_3_options
WHERE option_name LIKE '%weather_london%';

If a plugin called set_transient( 'weather_london', $data, 600 ) while switched to blog 2, the first query returns the value. If the same plugin later called get_transient( 'weather_london' ) while switched to blog 3, the second query returns nothing — unless the plugin used switch_to_blog() incorrectly or stored the transient in a global group. That is the first failure mode: a missing value that looks like a cache miss but is actually a key scoping error.

Reproducing the Collision with WP-CLI

Use wp transient commands to see the same behavior from the command line:

wp transient set weather_london 'rain' 600 --url=blog2.example.com
wp transient get weather_london --url=blog2.example.com
wp transient get weather_london --url=blog3.example.com

The first get returns rain. The second returns an empty result because blog 3 has no such transient. That is expected. The collision happens when a plugin stores the transient in a network-wide cache group or uses set_site_transient() with a key that is not unique per blog.

The Exact Collision: Network-Wide Transients and Shared Keys

WordPress has two transient APIs that operate at the network level:

  • set_site_transient( $key, $value, $expiration )
  • get_site_transient( $key )

These functions store data in the wp_sitemeta table or in a network-wide cache group. The key is not prefixed with a blog ID. If two plugins on different blogs both use set_site_transient( 'weather_london', ... ), the second call overwrites the first. The value from blog 2 leaks into blog 3, and the expiration timestamp is shared. This is the collision that causes real production bugs: a weather widget on blog 3 suddenly shows London weather because blog 2 updated the same key.

To reproduce this, run the following on a multisite install:

wp eval 'set_site_transient( "weather_london", "rain", 600 );' --url=blog2.example.com
wp eval 'set_site_transient( "weather_london", "sunny", 600 );' --url=blog3.example.com
wp eval 'var_dump( get_site_transient( "weather_london" ) );' --url=blog2.example.com

The output is sunny, not rain. Blog 2’s value was overwritten by blog 3 because both used the same network-wide key. This is not a bug in WordPress core; it is a consequence of the API contract. set_site_transient() is designed for network-wide data like update checks, not per-blog data.

Why Plugins Accidentally Use Network-Wide Transients

Most collisions come from three patterns:

  1. Copy-paste from single-site examples. A developer reads the Codex example for set_transient() and uses it in a multisite plugin without checking the context. The plugin works on a single site, but on multisite the transient is stored in the current blog’s options table only if the plugin is running in that blog’s context. If the plugin runs in a network admin context or during a cron job that iterates over blogs, the transient may be stored in the wrong blog’s table.
  2. Using set_site_transient() for per-blog data. Some developers assume site means the current site, not the network. They use set_site_transient() for per-blog data and create the exact collision described above.
  3. Hardcoded keys in shared libraries. A theme or plugin that is network-activated may use a hardcoded key like my_plugin_latest_posts in a global cache group. On a single site, that key is fine. On multisite, every blog shares the same key in the global group, so the value from the first blog to write wins.

How to Namespace Transient Keys Correctly

The fix is to make the transient key unique per blog and per context. The simplest pattern is to include the current blog ID in the key:

$blog_id = get_current_blog_id();
$key = 'weather_london_' . $blog_id;
set_transient( $key, $data, 600 );

This works for per-blog transients stored in the blog’s own options table. The key is unique across blogs because the blog ID is part of the key. When you retrieve the transient, you must build the same key:

$blog_id = get_current_blog_id();
$key = 'weather_london_' . $blog_id;
$data = get_transient( $key );

If you are using a persistent object cache like Redis or Memcached, the same pattern applies. The object cache backend may use a global key space, so the blog ID in the key prevents collisions there too.

Namespacing for Network-Wide Transients

If you genuinely need a network-wide transient, use set_site_transient() but make the key unique to the data you are storing. For example, if you are caching a network-wide list of active plugins, use a key like active_plugins_network. If you are caching per-blog data in a network-wide transient, include the blog ID in the key:

$blog_id = get_current_blog_id();
$key = 'weather_london_' . $blog_id;
set_site_transient( $key, $data, 600 );

This prevents the collision because blog 2 and blog 3 now use different keys. The data is still stored in the network-wide cache, but each blog’s value is isolated.

Using a Prefix Constant

For plugins that are distributed or used across many sites, define a prefix constant and use it in every transient call:

define( 'MY_PLUGIN_PREFIX', 'my_plugin_' );

function my_plugin_get_cached_weather( $city ) {
    $blog_id = get_current_blog_id();
    $key = MY_PLUGIN_PREFIX . 'weather_' . $city . '_' . $blog_id;
    return get_transient( $key );
}

This makes the key self-documenting and reduces the chance of a typo. It also makes it easy to flush all transients for the plugin by deleting keys that start with the prefix.

Flushing Transients Without Causing Collisions

When you flush transients, you must be careful not to delete transients that belong to other blogs. The delete_transient() function only deletes the transient for the current blog if you use a per-blog key. If you use a network-wide key, delete_site_transient() deletes it for the entire network.

To flush all transients for a specific blog, use WP-CLI:

wp transient delete --all --url=blog2.example.com

This deletes only the transients stored in blog 2’s options table. It does not touch blog 3’s transients. If you have used network-wide transients with blog-specific keys, you must delete them individually or use a custom cleanup routine.

Real Failure Mode: Cron Jobs and Switch_to_blog

A common production failure happens when a cron job iterates over blogs and calls switch_to_blog(). The transient key is built before the switch, so it uses the wrong blog ID. For example:

$blogs = get_sites();
foreach ( $blogs as $blog ) {
    switch_to_blog( $blog->blog_id );
    $key = 'weather_london_' . get_current_blog_id();
    set_transient( $key, $data, 600 );
    restore_current_blog();
}

This works because the key is built after the switch. But if the key is built before the switch, every blog gets the same key, and the transient is stored in the wrong blog’s options table. The fix is to always build the key inside the switched context.

Testing Your Transient Keys

To verify that your transient keys are namespaced correctly, run this WP-CLI command on a multisite install:

wp eval 'var_dump( get_current_blog_id() );' --url=blog2.example.com
wp eval 'var_dump( get_current_blog_id() );' --url=blog3.example.com

Then set a transient on blog 2 and try to get it on blog 3:

wp transient set weather_london_2 'rain' 600 --url=blog2.example.com
wp transient get weather_london_2 --url=blog3.example.com

The second command should return an empty result. If it returns rain, your object cache backend is sharing keys across blogs, and you need to add a blog ID to the key or configure the cache backend to use per-blog key prefixes.

FAQ

Why do transients collide in multisite but not in single-site installs?

In a single-site install, there is only one options table and one blog ID. The transient key is unique by default. In multisite, each blog has its own options table, but network-wide transients and shared object cache groups use a global key space. If a plugin uses the same key for per-blog data without including the blog ID, the values collide.

How can I tell if a transient collision is happening on my site?

Look for symptoms like a widget showing the wrong content on one blog, a scheduled event firing with the wrong site context, or a transient value that changes unexpectedly after another blog updates. You can also query the options tables directly to see if the same transient key exists in multiple blogs with different values.

What is the difference between set_transient() and set_site_transient()?

set_transient() stores data in the current blog’s options table or in a per-blog cache group. set_site_transient() stores data in the network-wide wp_sitemeta table or in a global cache group. Use set_transient() for per-blog data and set_site_transient() only for data that is truly network-wide.

Does WordPress core automatically namespace transient keys per blog?

No. WordPress core does not automatically prefix transient keys with the blog ID. The set_transient() function stores the key exactly as you pass it, in the current blog’s options table. The physical table separation prevents most collisions, but network-wide transients and shared object cache groups require manual namespacing.

For more on WordPress database behavior and troubleshooting, see What to Fix First When a New WordPress Site Says Nothing Found.

WordPress multisite database tables on a screen
Developer debugging transient keys in code editor
Server logs showing cache key collisions

How to Diagnose Why wp-cron Events Queue but Never Execute on Shared Hosting

If you run WordPress on shared hosting, you’ve probably seen it: a scheduled post that never publishes, a backup that never runs, a plugin that says its next event is overdue. The wp-cron system is WordPress’s built-in task scheduler, but on shared hosting it often queues events without ever executing them. This article is a field guide to diagnosing that failure mode. We’ll cover the difference between WP-Cron and a real system cron, the database rows that hold queued events, the HTTP request chain that triggers execution, and the specific shared-hosting conditions that break that chain. Every claim here is tied to a reproducible WP-CLI command, SQL query, or code snippet you can run on your own production install.

This matters for small-to-mid publishing teams because wp-cron is not just a convenience. It drives scheduled post transitions, editorial workflow reminders, comment moderation checks, and plugin housekeeping. When events queue but never run, the symptom is often silent: a missed publish time, a stale cache, a failed email digest. By the end of this article, you’ll be able to trace a queued event from the wp_options table to the HTTP request that should have run it, and you’ll know which shared-hosting settings to check first.

Server rack with network cables in a data center
Shared hosting environments often restrict the outbound HTTP requests that wp-cron depends on.

What wp-cron Actually Is

WordPress does not have a background daemon. Instead, it uses a web-triggered scheduler. On every page load, WordPress checks whether any scheduled events are due. If so, it sends an HTTP request to wp-cron.php in the WordPress root. That request runs the due events. The key file is wp-cron.php, and the scheduling logic lives in wp-includes/cron.php.

The queue itself is stored in the wp_options table under the option name cron. The value is a serialized PHP array. Each event has a timestamp, a hook name, and arguments. When an event is due, WordPress spawns a non-blocking HTTP request to wp-cron.php?doing_wp_cron=. That request runs the hook callbacks.

This design has a known failure mode: if no one visits the site, no page load occurs, and no cron runs. But on shared hosting, the more common failure is that page loads happen, the event is due, and the HTTP request to wp-cron.php still never completes. That’s the failure mode this article focuses on.

First Evidence: Check the Queue Directly

Before touching any configuration, look at the actual queued events. The fastest way is WP-CLI:

wp cron event list --fields=hook,next_run_relative,next_run

If you don’t have WP-CLI on the shared host, run this SQL query against the WordPress database:

SELECT option_value FROM wp_options WHERE option_name = 'cron';

The output is a serialized array. You can unserialize it with PHP:

php -r '$cron = get_option("cron"); print_r($cron);'

Or use a one-off script in a mu-plugin to dump the queue to the error log. The point is to confirm two things: the event exists, and its timestamp is in the past. If the timestamp is in the future, the event is simply not due yet. If it’s in the past and still listed, you have a queue-but-not-execute problem.

What a Stuck Queue Looks Like

A healthy queue shows events with next_run_relative values like now or 1 minute. A stuck queue shows events with next_run_relative values like 2 hours ago or 1 day ago. The event is due, but the hook never fired. This is the signature of a broken execution path, not a missing schedule.

Person typing on a laptop with code on the screen
WP-CLI gives you a direct view of the cron queue without waiting for a page load.

The Execution Path: From Page Load to wp-cron.php

When a visitor loads any page on your site, WordPress runs wp_cron() during the shutdown sequence. That function checks the cron option for due events. If it finds any, it calls spawn_cron(), which sends an HTTP request to wp-cron.php. The request is non-blocking: WordPress uses wp_remote_post() with a very short timeout, typically 0.01 seconds. The idea is to fire the request and let the server handle it in the background.

On shared hosting, this is where things break. The non-blocking request depends on the server being able to make an outbound HTTP connection to itself. Many shared hosts block loopback requests, or they restrict the PHP functions that wp_remote_post() uses, such as fsockopen() or curl. If the loopback request fails silently, the event stays queued.

Test the Loopback Request

You can test whether your server can make a loopback request with a small mu-plugin:

add_action('init', function() {
    if (isset($_GET['loopback_test'])) {
        $response = wp_remote_post(home_url('/wp-cron.php'), array(
            'timeout' => 5,
            'blocking' => true,
        ));
        if (is_wp_error($response)) {
            error_log('Loopback test failed: ' . $response->get_error_message());
        } else {
            error_log('Loopback test succeeded: ' . wp_remote_retrieve_response_code($response));
        }
        exit;
    }
});

Then visit https://yourdomain.com/?loopback_test=1 and check the PHP error log. If you see a timeout, a connection refused error, or a DNS failure, the loopback request is the problem. This is the single most common cause of queued-but-never-executed cron events on shared hosting.

Shared Hosting Failure Modes

Shared hosting environments introduce several specific failure modes that don’t appear on a VPS or dedicated server. Here are the ones I’ve seen most often in production installs.

1. Loopback Requests Are Blocked

Some hosts block outbound HTTP requests from PHP scripts as a security measure. This prevents a compromised script from sending spam or participating in a botnet. The side effect is that wp_remote_post() to your own domain fails. The fix is usually to disable WP-Cron and use a real system cron job, which we’ll cover below.

2. The Server Cannot Resolve Its Own Domain

On some shared hosts, the server’s DNS resolver cannot resolve the site’s own domain. The loopback request to https://yourdomain.com/wp-cron.php fails because the server cannot find the IP address. This is more common on hosts that use a CDN or a proxy in front of the origin server. You can test this by running wp eval 'echo wp_remote_retrieve_response_code(wp_remote_get(home_url("/")));' via WP-CLI. If it returns 0 or an error, DNS resolution is likely the issue.

3. PHP Execution Time Limits

Shared hosts often set max_execution_time to 30 seconds or less. The non-blocking cron request is designed to return immediately, but if the server is slow, the request can take longer than the timeout. When the timeout is hit, the request is aborted, and the event never runs. This is more common on hosts with oversold CPU resources.

4. The ALTERNATE_WP_CRON Fallback Is Not Set

WordPress has a fallback mechanism for hosts that block loopback requests. If you define ALTERNATE_WP_CRON as true in wp-config.php, WordPress will redirect the visitor’s browser to wp-cron.php instead of making a server-side loopback request. This works, but it has a cost: the visitor’s page load is delayed while the cron runs. For a publishing site with low traffic, this is often an acceptable tradeoff.

define('ALTERNATE_WP_CRON', true);

Add that line to wp-config.php and test again. If events start running, the loopback request was the problem.

Close-up of server status lights
Server-side loopback restrictions are a common culprit on shared hosting.

The System Cron Fix

The most reliable fix on shared hosting is to disable WP-Cron entirely and run the scheduler from a real system cron job. Most shared hosts provide a cron manager in their control panel, such as cPanel’s Cron Jobs tool. The steps are:

  1. Add define('DISABLE_WP_CRON', true); to wp-config.php.
  2. Create a system cron job that hits wp-cron.php directly on a schedule.

The cron job command depends on your host. For cPanel, it’s typically:

wget -q -O - https://yourdomain.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1

Or if wget isn’t available:

curl -s https://yourdomain.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1

Set the schedule to every 5 or 10 minutes. This bypasses the loopback request entirely because the system cron job runs from the server’s own scheduler, not from a PHP script. It also means cron runs even when no one visits the site.

One caveat: some shared hosts restrict the use of wget or curl in cron jobs. If that happens, you can use a PHP CLI command instead:

php /home/username/public_html/wp-cron.php

But this requires knowing the absolute path to your WordPress install, and it may not work if the host’s PHP CLI is configured differently from the web server’s PHP.

Diagnosing with WP-CLI

WP-CLI is the fastest way to test the cron system without waiting for a page load. Here are the commands I use most often.

List All Events

wp cron event list

Run a Specific Event Immediately

wp cron event run 

This runs the event synchronously, bypassing the HTTP request entirely. If the event runs successfully via WP-CLI but not via page load, the problem is in the HTTP execution path, not in the event callback itself.

Run All Due Events

wp cron event run --due-now

This is useful for clearing a backlog after you’ve fixed the underlying issue.

Check the Cron Option Directly

wp option get cron --format=json

This shows the raw serialized queue. If the option is missing or empty, WordPress will rebuild it on the next page load, but any custom schedules from plugins will be lost until those plugins re-register them.

Common Plugin Interactions

Some plugins add their own cron handlers and can mask or worsen the problem. For example, a backup plugin might schedule a daily event, but if the event never runs, the plugin shows a “next backup: overdue” notice. The fix is the same: diagnose the execution path, not the plugin.

One specific interaction to watch for: object caching plugins. If you use a persistent object cache like Redis or Memcached on shared hosting, the cron option can be cached. When WordPress updates the queue, the cache may not be invalidated, so the page load sees a stale queue and never spawns the cron request. If you suspect this, flush the object cache and test again.

When the Queue Itself Is Corrupt

Occasionally, the cron option becomes corrupt. This can happen if a plugin writes a malformed value, or if the database row is truncated. The symptom is a PHP warning about an invalid cron array, or events that appear and disappear unpredictably.

To check for corruption, run:

wp eval 'var_dump(_get_cron_array());'

If the output is false or contains unexpected types, the option is corrupt. The fix is to delete the option and let WordPress rebuild it:

wp option delete cron

Then visit the site once to trigger a rebuild. Note that this removes all scheduled events, including plugin events. Plugins will re-register their events on the next page load, but any one-off events will be lost.

Editorial Workflow Implications

For a publishing team, a stuck cron queue has direct editorial consequences. Scheduled posts don’t publish. Editorial reminder emails don’t send. Comment moderation queues don’t refresh. The fix isn’t to manually publish posts; it’s to fix the scheduler so the automated workflow works.

One practical step is to add a cron health check to your editorial dashboard. A simple mu-plugin can log the number of overdue events to the error log on every admin page load:

add_action('admin_init', function() {
    $cron = _get_cron_array();
    $overdue = 0;
    foreach ($cron as $timestamp => $events) {
        if ($timestamp < time()) {
            $overdue += count($events);
        }
    }
    if ($overdue > 0) {
        error_log('Overdue cron events: ' . $overdue);
    }
});

This gives you an early warning before a scheduled post misses its publish time. For a deeper look at how scheduled posts interact with the database, see What to Fix First When a New WordPress Site Says Nothing Found.

FAQ

Why do my scheduled posts sometimes publish late on shared hosting?

Scheduled posts rely on wp-cron. If the loopback request to wp-cron.php fails, the event stays queued until a page load successfully triggers it. On shared hosting, loopback restrictions or DNS resolution failures are the most common causes. Test the loopback request with the mu-plugin snippet above, and if it fails, switch to a system cron job.

Can I just disable wp-cron and run everything manually?

You can disable wp-cron with define('DISABLE_WP_CRON', true);, but you must replace it with a system cron job that hits wp-cron.php on a regular schedule. Otherwise, no scheduled events will run at all. The system cron approach is more reliable on shared hosting because it doesn’t depend on a page load or a loopback request.

How do I know if the cron queue is corrupt?

Run wp eval 'var_dump(_get_cron_array());'. If the output is false or contains unexpected types, the cron option is corrupt. Delete it with wp option delete cron and visit the site once to rebuild the queue. Plugins will re-register their events, but one-off events will be lost.

What is the difference between wp-cron and a real system cron?

wp-cron is a web-triggered scheduler: it runs only when someone visits the site, and it depends on an HTTP loopback request. A real system cron runs from the server’s scheduler at fixed intervals, independent of site traffic. On shared hosting, a system cron is more reliable because it bypasses the loopback request and runs even when no one visits the site.

Next Steps for Your Install

Start with the queue. Run wp cron event list and look for overdue events. Then test the loopback request. If it fails, either set ALTERNATE_WP_CRON or switch to a system cron job. Document the fix in your team’s runbook so the next person doesn’t have to rediscover it. And if you’re maintaining multiple production installs, consider a recurring column on this site for shared-hosting failure modes. The next topic worth covering is how to audit plugin cron registrations so you know exactly which events each plugin adds to the queue.

The Way WordPress Handles 404s for Attachment Pages (And Why It Confuses Crawlers)

WordPress attachment pages are a leftover from the pre-block-editor era, when every uploaded media file got its own URL and a template that rendered a single image or document. For a small-to-mid publishing team running its own production install, those attachment URLs are now a quiet source of crawl waste, soft-404 ambiguity, and index bloat. The core behavior is not a bug in the traditional sense: WordPress resolves an attachment URL through the attachment rewrite rules, queries the post_type=attachment post, and only falls back to a 404 when the attachment post itself is missing or the rewrite does not match. The confusion for crawlers comes from the gap between what WordPress considers a valid resource and what a search engine considers a useful landing page.

This article walks through the exact request path, the database rows involved, the HTTP status behavior, and the failure modes that show up in crawl logs. It is written for teams that maintain their own WordPress installs and want reproducible evidence before changing template behavior, redirect rules, or sitemap output.

WordPress attachment page code on a screen

What an Attachment Page Actually Is in Core

When you upload an image through the media library, WordPress creates a post of type attachment in the wp_posts table. The attachment post has a post_parent pointing to the post or page where the file was first uploaded, a post_mime_type such as image/jpeg, and a guid that contains the raw file URL. The attachment post also gets a post_name derived from the filename, which becomes the slug for the attachment page.

You can confirm this with a direct SQL query:

SELECT ID, post_title, post_name, post_parent, post_mime_type, guid
FROM wp_posts
WHERE post_type = 'attachment'
AND post_mime_type LIKE 'image/%'
ORDER BY ID DESC
LIMIT 10;

The attachment page URL is then built from the parent post permalink plus the attachment slug. For a parent post at /2024/09/editorial-workflow-notes/ and an attachment named newsroom-dashboard.png, the attachment URL becomes /2024/09/editorial-workflow-notes/newsroom-dashboard/. That URL is not a redirect to the file. It is a full WordPress page request that loads the attachment.php template if your theme has one, or falls back to single.php or index.php.

The Rewrite and Query Path

WordPress matches the attachment URL through the attachment rewrite rule generated by WP_Rewrite. The rule captures the parent path and the attachment slug, then passes them to index.php?attachment=$matches[1]. The main query then looks for a post of type attachment with that slug. If the attachment post exists, WordPress returns a 200 OK status and renders the template. If the attachment post does not exist, WordPress returns a 404 Not Found status through the normal WP_Query no-results path.

This is the first point of confusion for crawlers: a URL can return 200 OK even when the parent post is unpublished, trashed, or deleted. The attachment post remains in the database unless you explicitly delete the media item. A crawler that follows an old attachment URL from a sitemap, an RSS feed, or an external link can land on a page that shows only an image and a minimal title, with no editorial context and no clear navigation back to the parent article.

Why Crawlers Treat Attachment Pages as Soft 404s

Search engines do not rely only on the HTTP status code. They also evaluate whether a page provides substantive content that matches the query intent. An attachment page for a single image often contains no meaningful text beyond the image title, caption, and description fields. Many themes render the image at full size, add a comment form, and link back to the parent post. That is a thin page by any reasonable standard.

Google’s documentation on soft 404s describes the pattern: a page returns 200 OK but the content is so thin or irrelevant that the crawler treats it as a missing page. Attachment pages are a textbook case. The crawler wastes budget on URLs that will never rank, and the site accumulates index bloat that dilutes the signal from real editorial pages.

You can see the scale of the problem with a simple count:

SELECT COUNT(*) AS attachment_count
FROM wp_posts
WHERE post_type = 'attachment'
AND post_status = 'inherit';

On a site with five years of editorial images, that number can easily exceed the number of published articles. Each attachment URL is a potential crawl target unless you actively block or redirect it.

Crawl log showing attachment page requests

The post_status=inherit Detail

Attachment posts use the inherit post status, not publish. That status means the attachment inherits the status of its parent post. If the parent post is published, the attachment is publicly queryable. If the parent post is trashed, the attachment is not publicly queryable through the normal query, but the attachment post still exists in the database. This inheritance is what makes attachment URLs behave inconsistently after editorial changes.

For example, if you trash a parent post, the attachment URL may start returning a 404 because the parent is no longer available. If you restore the parent, the attachment URL returns 200 again. Crawlers that saw the 404 may not revisit the URL for a long time, and crawlers that saw the 200 before the trash may keep the stale URL in their index.

Reproducing the 404 and 200 Behavior

The fastest way to see the behavior is with WP-CLI and curl. First, find an attachment URL:

wp post list --post_type=attachment --post_mime_type=image/jpeg --format=ids --posts_per_page=1

Then request the URL with headers:

curl -I https://example.com/path-to-parent/attachment-slug/

You will see HTTP/2 200 for a valid attachment page. Now delete the attachment post directly in the database or through the media library, and request the same URL again. You will see HTTP/2 404. The difference is entirely in the wp_posts row, not in the file on disk. The actual image file can still exist in wp-content/uploads/ and be served correctly at its direct file URL, while the attachment page returns 404.

This split between the file URL and the attachment page URL is another source of crawler confusion. A crawler can fetch /wp-content/uploads/2024/09/newsroom-dashboard.png and get a 200 with the image bytes, then fetch /2024/09/editorial-workflow-notes/newsroom-dashboard/ and get a 404. The crawler has no reliable way to know that the two URLs are related unless the site provides a canonical or redirect signal.

What the Database Schema Tells You

The wp_posts table stores the attachment post, but the file metadata lives in wp_postmeta. The _wp_attached_file meta key holds the relative path to the uploaded file, and _wp_attachment_metadata holds a serialized array with sizes, dimensions, and image editor data. The attachment page URL is derived from the post_name and the parent post’s permalink, not from the file path.

This separation means you can change the file on disk without changing the attachment page URL, and you can change the attachment slug without moving the file. It also means that a broken attachment page can exist even when the file is perfectly intact. A crawler that follows the attachment page URL and gets a 200 with a broken image tag has no way to distinguish that from a real editorial page with a broken image.

To inspect the metadata for a specific attachment:

SELECT p.ID, p.post_name, pm.meta_key, pm.meta_value
FROM wp_posts p
LEFT JOIN wp_postmeta pm ON p.ID = pm.post_id
WHERE p.post_type = 'attachment'
AND p.ID = 12345
AND pm.meta_key IN ('_wp_attached_file', '_wp_attachment_metadata');

The serialized metadata is not queryable with normal SQL, but you can see the raw structure and confirm that the file path and the attachment page slug are independent values.

Common Failure Modes in Production Installs

Small-to-mid publishing teams usually hit three specific failure modes with attachment pages.

1. Sitemap and Index Bloat

If you use a sitemap plugin that includes attachment pages by default, every uploaded image gets a sitemap entry. A site with 10,000 images submits 10,000 thin URLs to search engines. The crawler spends budget on those URLs instead of your actual articles. You can check whether your sitemap includes attachments by looking for post_type=attachment in the sitemap XML or by running a quick crawl of your own sitemap with a tool like wget or curl.

The fix is usually a filter or a plugin setting that excludes attachment pages from the sitemap. In code, you can use the wp_sitemaps_post_types filter to remove the attachment post type from core sitemaps:

add_filter( 'wp_sitemaps_post_types', function( $post_types ) {
    unset( $post_types['attachment'] );
    return $post_types;
} );

This is a one-line change that prevents future sitemap bloat, but it does not fix URLs that are already indexed.

2. Soft 404s from Thin Templates

Even when the attachment page returns 200, the template may render so little content that search engines treat it as a soft 404. The default attachment.php in many classic themes shows the image, the caption, and a comment form. There is no article text, no related content, and no clear purpose for a reader who lands on the page from search.

You can test this by viewing the rendered HTML of an attachment page and counting the visible text. If the text is under 100 words and the page has no unique value, it is a soft-404 candidate. The fix is either to redirect attachment pages to the parent post or to the file URL, or to build a genuinely useful attachment template with context, metadata, and navigation. Most publishing teams choose the redirect because it is simpler and preserves crawl budget.

3. Orphaned Attachments After Parent Deletion

When you delete a parent post, WordPress does not automatically delete the attachment posts. The attachment posts remain with post_parent pointing to a non-existent post ID. The attachment page URL may return 404 because the parent is missing, but the attachment post still exists in the database. This creates a mismatch between the database state and the URL behavior.

You can find orphaned attachments with a SQL query:

SELECT a.ID, a.post_title, a.post_parent
FROM wp_posts a
LEFT JOIN wp_posts p ON a.post_parent = p.ID
WHERE a.post_type = 'attachment'
AND p.ID IS NULL;

These orphaned rows are not harmful by themselves, but they can confuse plugins that iterate over attachments, and they can produce unexpected 404s in crawl logs. A cleanup routine that deletes orphaned attachments or reassigns them to a valid parent is a reasonable maintenance task for a production install.

How to Decide: Redirect, Block, or Keep

The right choice depends on your editorial workflow and your archive strategy. There is no universal answer, but there are three defensible positions.

Redirect to the parent post. This is the most common choice for publishing teams. It preserves the link equity from any external links to the attachment page, sends readers to a useful page, and removes the thin page from the index. You can implement it with a template redirect in a child theme or a small plugin:

add_action( 'template_redirect', function() {
    if ( is_attachment() ) {
        global $post;
        if ( $post && $post->post_parent ) {
            wp_safe_redirect( get_permalink( $post->post_parent ), 301 );
            exit;
        }
    }
} );

This redirect sends every attachment page to its parent post. If the parent post is missing, the redirect falls through and the attachment page returns its normal 404 or 200 behavior. You can extend the snippet to redirect to the file URL instead, but that sends readers to a raw image with no editorial context, which is rarely useful.

Block attachment pages with a 404 or 410. Some teams prefer to return a hard 404 for all attachment pages, even when the attachment post exists. This is a stronger signal to crawlers that the URL should be removed from the index. The downside is that any external links to attachment pages will land on a 404, which is a poor user experience. If you choose this route, make sure your 404 template is useful and includes a search form and links to recent articles.

Keep attachment pages and improve the template. This is the least common choice, but it can work for sites that publish photography, infographics, or other visual content where the attachment page has standalone value. The template needs to include the image at a reasonable size, the caption, the description, the parent post link, related images, and enough text to avoid a soft 404. This is more work than a redirect, and it only makes sense if your attachment pages have a real audience.

WordPress database schema for attachment posts

What the Crawl Logs Actually Show

If you have access to server logs or a crawl tool, look for the pattern of attachment URLs being requested repeatedly. A typical log entry looks like this:

66.249.66.1 - - [12/Sep/2024:08:14:22 +0000] "GET /2024/09/editorial-workflow-notes/newsroom-dashboard/ HTTP/2" 200 1842 "-" "Googlebot/2.1 (+http://www.google.com/bot.html)"

The 200 status with a small response size is the signature of a thin attachment page. If you see hundreds of these requests per week, the crawler is spending budget on URLs that will never rank. After you implement a redirect, the same URL should return a 301 and the crawler should follow it to the parent post. The log entry changes to:

66.249.66.1 - - [12/Sep/2024:08:15:02 +0000] "GET /2024/09/editorial-workflow-notes/newsroom-dashboard/ HTTP/2" 301 0 "-" "Googlebot/2.1 (+http://www.google.com/bot.html)"

That 301 is the signal you want. It tells the crawler that the attachment URL is permanently moved, and it consolidates any link equity into the parent post.

Checking Your Own Install

Before changing anything, run a quick audit. Use WP-CLI to count attachments, check the sitemap, and sample a few attachment URLs:

wp post list --post_type=attachment --format=count
wp option get permalink_structure
wp eval 'var_dump( wp_sitemaps_get_server()->get_sitemaps() );'

Then request a sample of attachment URLs with curl -I and note the status codes. If you see a mix of 200, 301, and 404, your attachment handling is inconsistent. That inconsistency is what confuses crawlers most: the same type of URL behaves differently depending on the parent post status, the theme template, and the plugin stack.

For a deeper look at how WordPress handles missing content in general, see What to Fix First When a New WordPress Site Says Nothing Found. The 404 path for attachment pages shares the same query and template fallback logic, but the attachment-specific rewrite rules add an extra layer of indirection.

FAQ

Why does WordPress create attachment pages at all?

Attachment pages are a legacy feature from the early WordPress architecture, when every uploaded file was treated as a post-like object with its own URL. The attachment post type still exists in core because themes and plugins rely on it for media metadata, even though the standalone attachment page template is rarely useful for modern publishing sites.

Do attachment pages hurt SEO?

They can, but not because of a penalty. The harm comes from crawl budget waste, index bloat, and soft-404 signals. A site with thousands of thin attachment pages gives search engines more URLs to crawl without adding any substantive content. Redirecting or blocking attachment pages usually improves crawl efficiency and consolidates link equity into real editorial pages.

What is the difference between an attachment page 404 and a normal 404?

A normal 404 occurs when the requested URL does not match any rewrite rule or when the main query finds no post. An attachment page 404 occurs when the rewrite rule matches but the attachment post is missing, or when the parent post is unavailable and the attachment inherits that unavailable status. The HTTP status code is the same, but the underlying query path is different.

Can I disable attachment pages without a plugin?

Yes. The template_redirect snippet shown earlier is a complete solution for redirecting attachment pages to their parent posts. You can add it to a child theme’s functions.php or to a small custom plugin. For blocking attachment pages entirely, you can use the same hook to return a 404 or 410 status instead of redirecting.

Next Step for Your Install

Run the audit queries, check your sitemap, and sample a dozen attachment URLs. If you find thin pages returning 200, implement the redirect and monitor the crawl logs for the 301 pattern. Then document the decision in your team’s editorial workflow notes so that future uploads follow the same rule. This is a small change with a measurable impact on crawl efficiency, and it removes one of the quietest sources of index noise in a self-managed WordPress install.

How to Reconstruct a Dead Plugin’s Database Footprint Before Uninstalling

When a plugin stops getting updates, throws fatal errors on modern PHP, or just vanishes from the repository, the first impulse is to delete it and move on. That impulse is wrong for anyone running a production WordPress install. A dead plugin is not just a folder in wp-content/plugins. It is a set of rows in wp_options, a possible custom table or two, user meta entries, capabilities, cron events, and sometimes transients that still fire. If you uninstall without mapping that footprint, you leave orphaned data that can slow queries, confuse future migrations, or create conflicts with replacement plugins. This article shows how to reconstruct that footprint using SQL, WP-CLI, and the WordPress database schema itself before you remove anything.

This matters for small-to-mid publishing teams that maintain their own installs. You do not have a staging environment with a dedicated DBA. You have a production database, a backup plugin, and a terminal window. The goal is not to preserve dead code. The goal is to know exactly what the dead code left behind, so the uninstall is clean and reversible.

Start with the plugin’s declared schema, not its folder

Before touching the database, read the plugin’s main file and its uninstall routine. Many plugins register tables, options, and cron hooks in the main PHP file. Even if the plugin is dead, the code is still on disk and still readable. Look for register_activation_hook, register_deactivation_hook, register_uninstall_hook, and any dbDelta calls. These tell you what the plugin intended to create.

grep -R "register_activation_hook\|dbDelta\|add_option\|update_option\|wp_schedule_event" wp-content/plugins/dead-plugin/

This is not a complete map. Plugins often create options lazily, only when a feature is used. But the declared hooks give you the first layer: the plugin’s own assumptions about its footprint. Write those down. You will compare them against what actually exists in the database.

Inventory options with a prefix pattern

Most plugins store settings in wp_options using a consistent prefix. The prefix is usually the plugin slug or an abbreviation. If the plugin is called “Old Gallery Pro,” the options might be ogp_, old_gallery_, or ogpro_. You can find the prefix by grepping the plugin source for get_option and update_option calls.

grep -R "get_option\|update_option" wp-content/plugins/dead-plugin/ | head -50

Once you have candidate prefixes, query the options table. This query returns every option whose name starts with a given prefix, along with its autoload status and a truncated value. Autoload status matters because large autoloaded options are loaded on every request, even after the plugin is gone.

SELECT option_name, LENGTH(option_value) AS value_length, autoload
FROM wp_options
WHERE option_name LIKE 'ogp\_%'
ORDER BY option_name;

The underscore in the LIKE pattern is escaped because _ is a single-character wildcard in SQL. If you forget the backslash, you will match ogpX and ogp1 as well. That is a real failure mode when you are cleaning up after a plugin with a short prefix.

Do not stop at one prefix. Some plugins use multiple prefixes for different subsystems. A gallery plugin might use ogp_ for settings, ogp_album_ for album metadata, and ogp_cache_ for cached thumbnails. Grep the source for all get_option calls and collect every distinct prefix.

Find orphaned custom tables

Plugins that store large datasets often create custom tables. The table names usually follow the WordPress prefix, so a plugin with the slug old-gallery-pro might create wp_ogp_albums and wp_ogp_photos. To find them, list all tables that are not part of the core WordPress schema.

SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name NOT IN (
  'wp_commentmeta', 'wp_comments', 'wp_links', 'wp_options',
  'wp_postmeta', 'wp_posts', 'wp_term_relationships',
  'wp_term_taxonomy', 'wp_termmeta', 'wp_terms',
  'wp_usermeta', 'wp_users'
);

This returns every non-core table, including tables from other plugins and any custom tables you created yourself. You need to match the table names against the dead plugin’s source. Grep for CREATE TABLE and $wpdb->prefix in the plugin folder.

grep -R "CREATE TABLE\|\$wpdb->prefix" wp-content/plugins/dead-plugin/

If the plugin used dbDelta, the table creation statements are usually in an includes or admin subfolder. The table names will be concatenated from $wpdb->prefix and a literal string. That literal string is what you look for in the information_schema output.

Before dropping any table, export it. A dead plugin’s table might contain data you need for a migration, or it might be the only record of a content relationship that a replacement plugin needs to rebuild. Use mysqldump or WP-CLI to export the table to a file, then store that file outside the web root.

wp db export --tables=wp_ogp_albums,wp_ogp_photos /tmp/dead-plugin-tables.sql

Trace user meta and capabilities

Plugins that add roles or capabilities write to wp_usermeta and wp_options. A membership plugin might add a wp_capabilities entry for a custom role, or a wp_user_level value. A plugin that stores per-user preferences writes to wp_usermeta with a key like ogp_user_settings.

To find user meta left by the dead plugin, query for keys that match the plugin’s prefix or slug.

SELECT user_id, meta_key, meta_value
FROM wp_usermeta
WHERE meta_key LIKE '%ogp%'
OR meta_key LIKE '%old_gallery%'
ORDER BY meta_key, user_id;

Capabilities are trickier. They are stored as serialized arrays in wp_options under the key wp_user_roles, and as per-user serialized arrays in wp_usermeta under wp_capabilities. If the dead plugin registered a custom role, that role is still in the wp_user_roles option. You can inspect it with WP-CLI.

wp role list --fields=role,name

If you see a role that only the dead plugin used, note it. Removing the role is not as simple as deleting the option. You need to remove the role from every user who has it, then remove the role definition. WP-CLI can do this, but only after you have confirmed no other plugin or theme depends on that role.

wp role exists ogp_editor
wp user list --role=ogp_editor --fields=ID,user_login

If the role exists and has users, reassign those users to a standard role before removing the custom role. Otherwise you leave users with a capability set that no longer resolves to a defined role, which can cause unexpected access denials or, worse, unexpected access grants if a future plugin reuses the same role slug.

Check cron events and scheduled tasks

Dead plugins often leave scheduled events in wp_options under the cron option. These events fire on every page load if their scheduled time has passed, and they call functions that no longer exist. That produces PHP warnings in your error log and, in some cases, fatal errors that take down the site.

List all scheduled events and look for hooks that match the dead plugin’s slug or function names.

wp cron event list --fields=hook,next_run_relative,recurrence

If you see a hook like ogp_daily_cleanup or old_gallery_sync, that is a leftover. You can remove it with WP-CLI.

wp cron event delete ogp_daily_cleanup

But before deleting, check whether the hook is registered anywhere else. A theme or a must-use plugin might have taken over the hook. Grep the entire wp-content directory for the hook name.

grep -R "ogp_daily_cleanup" wp-content/

If the only match is in the dead plugin’s folder, the event is safe to remove. If the hook appears in a theme or another plugin, you need to understand that dependency before deleting the event.

Inspect transients and object cache leftovers

Transients are stored in wp_options with a _transient_ prefix. They expire, but a dead plugin’s transients might have long expiration times or might be set to autoload. A plugin that cached external API responses might have left hundreds of transients that are still being loaded on every request.

SELECT option_name, LENGTH(option_value) AS value_length, autoload
FROM wp_options
WHERE option_name LIKE '\_transient\_ogp%'
OR option_name LIKE '\_transient\_timeout\_ogp%'
ORDER BY option_name;

The _transient_timeout_ entries are companion rows that store the expiration timestamp. If you delete the transient but not the timeout, WordPress will try to read a missing transient and then delete the orphaned timeout on the next request. That is harmless but messy. Delete both.

DELETE FROM wp_options
WHERE option_name LIKE '\_transient\_ogp%'
OR option_name LIKE '\_transient\_timeout\_ogp%';

If you use an object cache like Redis or Memcached, transients might be stored there instead of the database. Flush the object cache after deleting the database rows, or the old values will be served until the cache expires.

wp cache flush

Map post meta and taxonomy terms

Plugins that extend content types often write to wp_postmeta and wp_term_taxonomy. A gallery plugin might store image metadata in wp_postmeta with keys like _ogp_image_id or _ogp_album_order. A plugin that adds custom taxonomies might have registered a taxonomy that is still present in wp_term_taxonomy.

Find post meta keys that match the plugin’s prefix.

SELECT meta_key, COUNT(*) AS row_count
FROM wp_postmeta
WHERE meta_key LIKE '\_ogp%'
OR meta_key LIKE 'ogp%'
GROUP BY meta_key
ORDER BY row_count DESC;

Do not delete these rows yet. Some post meta is used by the block editor or by other plugins that read the same keys. A replacement gallery plugin might import the old plugin’s post meta to rebuild galleries. Export the rows first, then decide whether to delete them.

For taxonomies, check wp_term_taxonomy for taxonomy names that match the dead plugin.

SELECT taxonomy, COUNT(*) AS term_count
FROM wp_term_taxonomy
WHERE taxonomy LIKE '%ogp%'
OR taxonomy LIKE '%old_gallery%'
GROUP BY taxonomy;

If the dead plugin registered a custom taxonomy, the terms are still in wp_terms and wp_term_taxonomy. The taxonomy itself is registered in code, so once the plugin is deleted, the taxonomy no longer exists. But the term rows remain. They are orphaned data. You can delete them, but first check whether any posts are still assigned to those terms.

SELECT p.ID, p.post_title
FROM wp_posts p
INNER JOIN wp_term_relationships tr ON p.ID = tr.object_id
INNER JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
WHERE tt.taxonomy = 'ogp_album'
LIMIT 50;

If posts are assigned to the dead taxonomy, you need to decide what to do with those assignments. Deleting the terms will remove the assignments, but the posts themselves remain. That is usually the correct outcome, but only after you have confirmed the posts do not rely on the taxonomy for display or routing.

Reconstruct the full footprint in a single report

You can combine these queries into a single WP-CLI command or a SQL script that outputs a complete footprint report. The report should include options, tables, user meta, cron events, transients, post meta, and taxonomy terms. This is the document you review before uninstalling.

wp db query "SELECT 'options' AS type, option_name AS name, LENGTH(option_value) AS size, autoload AS extra FROM wp_options WHERE option_name LIKE 'ogp\\_%' UNION ALL SELECT 'tables', table_name, 0, '' FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE 'wp\\_ogp%' UNION ALL SELECT 'usermeta', meta_key, 0, '' FROM wp_usermeta WHERE meta_key LIKE '%ogp%' UNION ALL SELECT 'postmeta', meta_key, COUNT(*), '' FROM wp_postmeta WHERE meta_key LIKE '\\_ogp%' GROUP BY meta_key;"

The escaping in this query is ugly because WP-CLI passes the SQL through a shell. If you are running this from a SQL client, you can simplify the escaping. The point is to produce one output that shows every database object the dead plugin touched.

Save that report to a file. It is your rollback plan. If the uninstall breaks something, the report tells you exactly what to restore.

What to do before you click delete

Once you have the footprint report, take a full database backup. Do not rely on the report alone. A backup is the only way to restore the exact state if something goes wrong.

wp db export /backups/pre-uninstall-dead-plugin-$(date +%Y%m%d).sql

Then deactivate the plugin, but do not delete it. Deactivation triggers the plugin’s deactivation hook, which might clean up some data or leave it in a different state. Check the footprint again after deactivation. If the deactivation hook removed some options or cron events, your uninstall plan changes.

Only after deactivation and a second footprint check should you delete the plugin. And even then, prefer deleting via WP-CLI or the admin interface, not by removing the folder over FTP. The admin delete process runs the plugin’s uninstall hook if it has one. That hook might clean up data you would otherwise have to remove manually.

wp plugin deactivate dead-plugin
wp plugin delete dead-plugin

After deletion, run the footprint queries again. Anything that remains is orphaned data. You can now remove it manually, using the report as your checklist.

Common failure modes when skipping this process

The most common failure is autoloaded options. A dead plugin that stored a large serialized array in wp_options with autoload = 'yes' continues to load that array on every request. If the array is a few megabytes, your site’s memory usage stays elevated forever. You can find the worst offenders with this query.

SELECT option_name, LENGTH(option_value) AS value_length
FROM wp_options
WHERE autoload = 'yes'
ORDER BY value_length DESC
LIMIT 20;

If any of the top entries match the dead plugin’s prefix, that is your smoking gun. Delete them after the uninstall.

Another failure mode is a leftover cron event that calls a missing function. WordPress fires the event, the function does not exist, and PHP logs a fatal error. If the event is scheduled to run frequently, your error log fills up and your site’s performance degrades. The fix is to delete the event, but only after confirming the hook is not registered elsewhere.

A third failure mode is a custom table that a replacement plugin tries to reuse. If the replacement plugin has the same table name but a different schema, the old table causes a conflict. The replacement plugin’s activation routine might fail, or it might write data into a table with the wrong columns. Dropping the old table before installing the replacement prevents this.

When to keep the data instead of deleting it

Not every orphaned row should be deleted. If you plan to migrate to a replacement plugin, the old plugin’s data might be the only source of truth for content relationships, user preferences, or historical records. In that case, export the data and keep the export file. You can delete the database rows after the migration is complete and verified.

If the dead plugin stored content in custom tables, those tables might contain data that belongs in wp_posts or wp_postmeta. A migration script can read the old tables and write the data into the new plugin’s format. That script is easier to write if the old tables still exist. So do not drop them until the migration is done.

If the dead plugin registered a custom post type, the posts of that type are still in wp_posts with a post_type value that no longer resolves. Those posts are invisible in the admin unless you register the post type again. You can either delete them or convert them to a standard post type. Converting is often better for SEO, because the posts might have inbound links.

UPDATE wp_posts
SET post_type = 'post'
WHERE post_type = 'ogp_gallery'
AND post_status = 'publish';

This is a destructive operation. Back up first. And check whether the posts have meta boxes or taxonomies that only make sense for the old post type. Converting the post type does not convert the meta.

Document the footprint for the next person

After the uninstall is complete, write a short note in your team’s internal documentation. Include the plugin name, the date, the footprint report, and what you deleted. If a future team member wonders why a certain option is missing or why a table no longer exists, the note answers the question.

This is not busywork. Production WordPress installs accumulate decisions. A dead plugin’s footprint is a decision someone made years ago. If you do not record the cleanup, the next person has to reconstruct it from scratch. That is wasted time and a real risk of deleting something important.

If your team maintains multiple installs, consider a recurring column or internal checklist for plugin retirements. The same process applies to themes, but themes have a smaller database footprint. The discipline of mapping before deleting is the same.

FAQ

How do I know if a dead plugin left autoloaded options?

Run a query against wp_options that filters for autoload = 'yes' and sorts by value length. If any option names match the dead plugin’s prefix or slug, those are autoloaded leftovers. You can also use WP-CLI to list autoloaded options and their sizes.

wp option list --autoload=yes --fields=option_name,option_value --format=table | head -50

The option_value field is truncated in the table view, but the option names are enough to identify the plugin.

What is the safest order for uninstalling a dead plugin?

Deactivate first, then check the footprint again, then delete via the admin or WP-CLI, then run the footprint queries a third time. The deactivation hook might clean up some data. The uninstall hook might clean up more. Only after both hooks have run should you manually remove what remains. Always take a full database backup before deactivation.

Can I just delete the plugin folder and ignore the database?

You can, but you will leave orphaned rows that continue to affect performance and can cause conflicts later. Autoloaded options still load on every request. Cron events still fire and call missing functions. Custom tables still take up space. If you never install a replacement plugin, the damage is mostly invisible. If you do install a replacement, the orphaned data can cause real conflicts.

How do I find the option prefix for a plugin that is already deleted?

If the plugin folder is gone, you cannot grep the source. Instead, look for option names that contain the plugin’s slug or a likely abbreviation. You can also check your backup files for the plugin’s source code. If you have a full backup from before the deletion, extract the plugin folder from the backup and grep it. If you have no backup, you are guessing. That is why the footprint report should be created before deletion.

For more on diagnosing a WordPress site that returns nothing, see What to Fix First When a New WordPress Site Says Nothing Found. The same diagnostic discipline applies here: check the database before assuming the problem is in the code.

Person reviewing database tables on a laptop screen

Close-up of SQL query results in a terminal window

Team members discussing a cleanup plan around a desk