search expand

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

Why Your Block Styles Enqueue in the Wrong Order When theme.json and PHP Both Declare Them

Block styles in WordPress are the CSS rules that shape how core blocks, custom blocks, and theme-defined patterns render on the front end. When a theme declares styles in theme.json and also enqueues stylesheets through PHP, the resulting cascade order can break your most deliberate design decisions. This matters for small-to-mid publishing teams that maintain their own production installs because a single misplaced stylesheet can override editorial components, alter the reading experience, and force editors to fight the block editor instead of publishing. The failure mode is not theoretical: it appears in the wp_enqueue_scripts hook, in the generated global-styles-inline-css output, and in the order WordPress prints styles in the document head.

This article explains the exact mechanism behind the ordering problem, shows how to reproduce it with a minimal theme, and gives you WP-CLI and SQL checks to confirm what is happening on your own site. The focus is on WordPress core internals, database schema, block editor behavior, and editorial workflow automation for teams that run their own production installs.

Code editor showing CSS and PHP files side by side

The Core Conflict: Two Declaration Paths, One Cascade

WordPress supports two primary ways for a theme to declare block styles. The first is theme.json, the configuration file that defines global styles, block-specific styles, and settings for the block editor. The second is the traditional PHP enqueue system, where a theme calls wp_enqueue_style() or wp_enqueue_block_style() inside a hook such as wp_enqueue_scripts or enqueue_block_assets. Both paths are valid, but they are processed at different times and with different priorities.

When both paths declare styles for the same block, the final order in the document head is not always what you expect. WordPress prints theme.json-generated styles through the global-styles-inline-css handle, which is registered and enqueued by the core block styles engine. PHP-enqueued stylesheets are printed according to their own dependencies and priorities. The result is that a PHP stylesheet with a lower priority can load before the inline global styles, or after them, depending on the hook and the dependency graph.

The practical consequence is that a style declared in theme.json can be overridden by a PHP stylesheet that loads later, even if the PHP stylesheet was intended to be a fallback. Conversely, a PHP stylesheet that loads before the inline global styles can be overridden by the theme.json output, which may surprise developers who assumed PHP always wins.

How WordPress Generates and Prints Block Styles

To understand the ordering problem, you need to know the sequence WordPress follows when it builds the front-end page. The block styles engine reads theme.json during the wp_enqueue_scripts action, but the actual CSS is generated later, when the wp_print_styles action runs. The generated CSS is attached to the global-styles-inline-css handle, which is a dependency of the global-styles handle. This means the inline CSS is printed after the global-styles stylesheet, but before any stylesheet that depends on global-styles.

PHP-enqueued stylesheets do not automatically depend on global-styles. If you enqueue a stylesheet with wp_enqueue_style() and do not declare a dependency on global-styles, WordPress may print it before the inline global styles. This is the most common cause of the wrong order: a PHP stylesheet that loads before the theme.json output, so the theme.json rules win.

Here is a minimal reproduction. Create a theme with a theme.json that sets a custom color for the core paragraph block:

{
  "version": 2,
  "settings": {
    "color": {
      "palette": [
        {
          "slug": "brand",
          "color": "#0a5c8a",
          "name": "Brand"
        }
      ]
    }
  },
  "styles": {
    "blocks": {
      "core/paragraph": {
        "color": {
          "text": "var(--wp--preset--color--brand)"
        }
      }
    }
  }
}

Then enqueue a PHP stylesheet in functions.php that sets the paragraph color to red:

add_action( 'wp_enqueue_scripts', function() {
    wp_enqueue_style(
        'my-theme-paragraph-override',
        get_stylesheet_directory_uri() . '/paragraph-override.css',
        array(),
        '1.0.0'
    );
} );

In paragraph-override.css, add:

p {
    color: red;
}

On the front end, the paragraph text will be the brand color from theme.json, not red. The reason is that the PHP stylesheet is printed before the inline global styles, so the theme.json rule wins. If you inspect the document head, you will see the PHP stylesheet link before the style id="global-styles-inline-css" block.

Browser inspector showing stylesheet order in the document head

Reproducing the Order with WP-CLI and SQL

You can confirm the order without opening a browser. First, use WP-CLI to list the enqueued styles and their dependencies:

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

This prints the queue in the order WordPress will process it. If my-theme-paragraph-override appears before global-styles-inline-css, you have the ordering problem. You can also check the dependencies of a specific handle:

wp eval 'global $wp_styles; var_dump( $wp_styles->registered["my-theme-paragraph-override"]->deps );'

If the dependencies array is empty, the stylesheet has no relationship to global-styles, and WordPress will print it in the default order.

For a database-level check, you can query the wp_posts table for the wp_global_styles post type, which stores the compiled theme.json data. This is useful when you suspect that a cached or stale global styles post is affecting the output:

SELECT ID, post_title, post_content FROM wp_posts WHERE post_type = 'wp_global_styles' AND post_status = 'publish';

The post_content field contains the JSON representation of the global styles, including any block-specific rules. If you see rules that you did not expect, the database may be holding an older version of theme.json that is still being used by the block styles engine.

Why the Hook and Dependency Graph Matter

The order problem is not just about theme.json versus PHP. It is about the hook you use and the dependency graph you build. WordPress processes styles in the order they are enqueued, but dependencies can reorder the queue. If you enqueue a PHP stylesheet on wp_enqueue_scripts with a dependency on global-styles, WordPress will print it after the inline global styles. This is the correct way to ensure that your PHP stylesheet can override theme.json rules when you intend it to.

Here is the corrected enqueue:

add_action( 'wp_enqueue_scripts', function() {
    wp_enqueue_style(
        'my-theme-paragraph-override',
        get_stylesheet_directory_uri() . '/paragraph-override.css',
        array( 'global-styles' ),
        '1.0.0'
    );
} );

With this dependency, the PHP stylesheet is printed after the inline global styles, and the red color wins. The dependency graph is the key to controlling the cascade order when both declaration paths are in play.

However, there is a tradeoff. If you add a dependency on global-styles, your stylesheet will not load if the global styles handle is not enqueued. In most block themes, global-styles is always enqueued, but in a classic theme that does not use theme.json, the handle may not exist. You need to check for the handle before adding the dependency, or use a conditional enqueue.

The Block Editor Side: Editor Styles and Front-End Styles

The ordering problem also appears in the block editor, but the mechanism is slightly different. In the editor, WordPress enqueues editor styles through the enqueue_block_editor_assets hook, and it also generates editor-specific inline styles from theme.json. The editor uses an iframe for the block canvas, and the styles are loaded inside that iframe. If you enqueue a PHP stylesheet for the editor without the correct dependency, it can load before the theme.json editor styles, causing the same override problem in the editing experience.

For editorial teams, this is a workflow issue. If the editor preview does not match the front end, editors will make decisions based on incorrect styling. They may add inline styles to blocks to compensate, which then creates a new layer of overrides that is even harder to debug. The fix is the same: declare a dependency on the appropriate global styles handle for the editor context.

Here is an example for the editor:

add_action( 'enqueue_block_editor_assets', function() {
    wp_enqueue_style(
        'my-theme-editor-override',
        get_stylesheet_directory_uri() . '/editor-override.css',
        array( 'wp-edit-blocks' ),
        '1.0.0'
    );
} );

The wp-edit-blocks handle is a dependency of the editor styles, but it does not guarantee that your stylesheet loads after the theme.json editor styles. To be precise, you should depend on the handle that WordPress uses for the editor global styles, which is wp-block-editor in recent versions. Test the order in the editor with the browser inspector, and adjust the dependency until your stylesheet appears after the inline global styles.

Database Schema and the Global Styles Post

The wp_global_styles post type is part of the database schema that WordPress uses to store theme.json data. When you save changes in the site editor, WordPress writes a new wp_global_styles post with the modified JSON. This post is then used to generate the inline CSS on the front end. If you have a stale wp_global_styles post, the generated CSS may not match your current theme.json, which can make the ordering problem appear even when your PHP enqueue is correct.

You can check for stale global styles posts with WP-CLI:

wp post list --post_type=wp_global_styles --post_status=publish --format=table

If you see multiple posts, the most recent one is the active global styles. Older posts are kept for revisions, but they should not affect the front end. If you suspect a stale post is causing problems, you can delete the revisions with a SQL query, but be careful: this is a destructive operation. Always back up the database first.

DELETE FROM wp_posts WHERE post_type = 'wp_global_styles' AND post_status = 'inherit';

This removes the revision posts, leaving only the published global styles. After running this, clear any caching plugins and re-check the front-end order.

Practical Fixes for Production Installs

The most reliable fix is to stop declaring the same block styles in two places. Choose one source of truth for each block style. If a style belongs to the theme’s design system, declare it in theme.json. If a style is a one-off override for a specific template or context, enqueue it through PHP with a dependency on global-styles. This separation prevents the cascade from becoming a guessing game.

For teams that need to override theme.json styles from PHP, the dependency approach is the correct pattern. Here is a complete example that works in a block theme:

add_action( 'wp_enqueue_scripts', function() {
    $handle = 'my-theme-contextual-override';
    $src = get_stylesheet_directory_uri() . '/contextual-override.css';
    $deps = array( 'global-styles' );
    wp_enqueue_style( $handle, $src, $deps, '1.0.0' );
} );

In contextual-override.css, use the same specificity as the theme.json rule, or higher, to ensure the override applies. For example, if theme.json targets .wp-block-paragraph, your PHP stylesheet should target the same class or a more specific selector.

If you need to override a theme.json style only in the editor, use the enqueue_block_editor_assets hook and depend on the editor global styles handle. Test the order in the editor iframe, because the dependency graph in the editor is not identical to the front end.

When the Order Is Correct but the Style Still Fails

Sometimes the enqueue order is correct, but the style still does not apply. This can happen when the CSS specificity of the theme.json rule is higher than your PHP rule. WordPress generates theme.json styles with a specific selector structure, often using :root and .wp-block-* classes. If your PHP stylesheet uses a less specific selector, the theme.json rule wins even if it loads earlier.

To debug this, inspect the computed style in the browser and look at the selector that is winning. Then adjust your PHP stylesheet to use a selector with equal or higher specificity. You can also use the !important flag, but that creates a new layer of overrides that is hard to maintain. A better approach is to match the selector structure that WordPress generates.

Here is an example. If theme.json generates:

.wp-block-paragraph {
    color: var(--wp--preset--color--brand);
}

Your PHP override should use the same class:

.wp-block-paragraph {
    color: red;
}

If you use p as the selector, the .wp-block-paragraph rule has higher specificity and wins. This is a common mistake that looks like an enqueue order problem but is actually a specificity problem.

Editorial Workflow Implications

For a publishing team, the wrong block style order is not just a technical annoyance. It changes how editors perceive the content they are working on. If the editor preview shows a different style than the front end, editors will make formatting decisions based on false information. They may add inline styles, change block settings, or restructure content to compensate. This creates a feedback loop of overrides that makes the site harder to maintain.

The fix is to treat the block style order as part of the editorial workflow. When you change a style in theme.json, test the front end and the editor immediately. Use a staging environment that mirrors the production database, including the wp_global_styles posts. If you use a deployment process, include a step that checks the enqueue order with WP-CLI before the site goes live.

One practical approach is to add a WP-CLI command to your deployment script that prints the style queue and fails if a known override handle appears before global-styles-inline-css. This is a simple guard that catches the ordering problem before it reaches production.

Editor reviewing block styles in WordPress admin

Common Failure Modes and Their Symptoms

Here are the failure modes I see most often in production installs, along with the symptoms and the fix.

Failure Mode 1: PHP Stylesheet Loads Before Global Styles

Symptom: A style declared in theme.json overrides a PHP stylesheet, even though the PHP stylesheet was intended to be the override.

Cause: The PHP stylesheet has no dependency on global-styles.

Fix: Add array( 'global-styles' ) to the dependencies in wp_enqueue_style().

Failure Mode 2: Stale Global Styles Post

Symptom: The front end shows styles that do not match the current theme.json, even after clearing the cache.

Cause: A wp_global_styles post in the database is out of date.

Fix: Check the wp_global_styles posts with WP-CLI, delete revisions if necessary, and re-save the site editor settings.

Failure Mode 3: Specificity Mismatch

Symptom: The enqueue order is correct, but the PHP style still does not apply.

Cause: The theme.json rule has higher specificity than the PHP rule.

Fix: Match the selector structure of the generated theme.json CSS, or increase the specificity of the PHP rule.

FAQ

Why does my PHP stylesheet load before the theme.json styles?

Your PHP stylesheet loads before the theme.json styles because it has no dependency on the global-styles handle. WordPress prints styles in the order they are enqueued, and the inline global styles are attached to the global-styles-inline-css handle, which is a dependency of global-styles. If your stylesheet does not declare global-styles as a dependency, it will be printed before the inline global styles.

How do I make a PHP stylesheet override theme.json styles?

Add array( 'global-styles' ) as the dependencies argument in wp_enqueue_style(). This tells WordPress to print your stylesheet after the inline global styles, so your rules can override the theme.json output. Make sure your CSS selectors have equal or higher specificity than the generated theme.json selectors.

Can I check the enqueue order without opening a browser?

Yes. Use WP-CLI to print the style queue: wp eval 'global $wp_styles; foreach ( $wp_styles->queue as $handle ) { echo $handle . "\n"; }'. If your PHP stylesheet handle appears before global-styles-inline-css, the order is wrong. You can also check the dependencies of a specific handle with wp eval 'global $wp_styles; var_dump( $wp_styles->registered["your-handle"]->deps );'.

What is the wp_global_styles post type?

The wp_global_styles post type stores the compiled theme.json data in the WordPress database. When you save changes in the site editor, WordPress writes a new wp_global_styles post. The block styles engine reads this post to generate the inline CSS on the front end. If the post is stale, the generated CSS may not match your current theme.json.

Next Steps for Your Site

If you are maintaining a production install, start by auditing your current enqueue order. Run the WP-CLI command above and look for any PHP stylesheet that appears before global-styles-inline-css. Then check the wp_global_styles posts for staleness. Fix the dependencies first, because that is the most common cause of the wrong order. After that, test the editor preview against the front end to make sure the editorial workflow is not being distorted by the cascade.

This topic connects to a broader question about how WordPress handles the relationship between the database, the block editor, and the front end. If you are also dealing with a site that returns nothing found on new posts, the fix may be related to the rewrite rules or the database schema. See What to Fix First When a New WordPress Site Says Nothing Found for a concrete debugging path.

For a deeper look at the block styles engine, the WordPress core source for wp-includes/global-styles-and-settings.php is the authoritative reference. The function wp_enqueue_global_styles() shows how the global-styles handle is registered and how the inline CSS is attached. Reading that source will give you the exact sequence of events, which is more reliable than any summary.

The next time you change a block style, do it in one place. If you must override from PHP, declare the dependency and test the order. Your editors will see the same styles you see, and your production install will stop fighting itself.

How to Handle WordPress Site Health Tests That Pass But Mask Real Problems

WordPress Site Health is a diagnostic surface, not a guarantee. A green checkmark means a test ran and matched a threshold. It does not mean the underlying subsystem is healthy under production load, correctly configured for your hosting topology, or safe from silent failure. For small-to-mid publishing teams that maintain their own installs, the dangerous failures are the ones Site Health never flags: object cache backends that report connected but evict constantly, database tables that pass integrity checks while accumulating dead rows, cron jobs that fire but never complete, and block editor requests that return 200 with empty content. This article covers the specific tests that pass while masking real problems, how to reproduce each failure mode, and what to monitor instead.

Site Health sits in the WordPress admin under Tools → Site Health. It runs a set of server, database, and WordPress configuration checks. The checks are useful for baseline triage, but they are static. They do not simulate traffic, measure latency under concurrency, inspect query plans, or validate that scheduled events actually produce output. If you run a production install with real editorial deadlines, you need a second layer of checks that target the gaps.

Why a Passing Site Health Test Can Be a False Positive

Site Health tests are designed to be safe for shared hosting and low-privilege environments. That constraint limits what they can inspect. A test may check that a PHP extension is loaded, not that it is configured correctly. It may check that a database table exists, not that its indexes are being used. It may check that a cron event is scheduled, not that it ran successfully. The result is a set of green indicators that can coexist with real production failures.

For example, the object cache test reports whether a persistent object cache is in use. It does not report the cache hit rate, eviction rate, or whether the cache backend is actually faster than a database query. A misconfigured Redis instance with maxmemory set too low will pass the test while evicting nearly every key. The site will work, but every page load will fall back to the database, and the cache will add latency instead of removing it.

Similarly, the database test checks that tables are present and accessible. It does not check for index fragmentation, dead rows, or missing indexes on high-traffic queries. A table with millions of rows and no index on a frequently filtered column will pass the test while every query that touches it runs a full table scan.

Object Cache: Connected Is Not the Same as Effective

The Site Health object cache test checks whether a persistent object cache drop-in is active. It does not measure whether the cache is doing its job. To see the real state of your object cache, you need to inspect the backend directly.

Reproduce the Failure: Redis Evictions

If you use Redis, connect to the instance and run:

redis-cli INFO stats | grep -E 'evicted_keys|keyspace_hits|keyspace_misses'

If evicted_keys is climbing while keyspace_misses is high relative to keyspace_hits, your cache is thrashing. The fix is usually to raise maxmemory, switch to a more memory-efficient serialization, or reduce the number of large transient keys. Site Health will still show green because the drop-in is present.

For Memcached, the equivalent check is:

echo "stats" | nc 127.0.0.1 11211 | grep -E 'evictions|get_hits|get_misses'

A high eviction count with a low hit ratio means the cache is not reducing database load. You are paying for the cache in memory and latency, but getting none of the benefit.

What to Monitor Instead

Track cache hit rate, eviction rate, and average latency for cache operations. Set alerts when the hit rate drops below 80% for a sustained period or when evictions exceed a threshold per minute. These metrics are not visible in Site Health, but they are the ones that predict slow page loads and database saturation.

Database Tables: Present Is Not the Same as Healthy

The Site Health database test checks that all core tables exist and are accessible. It does not check for dead rows, index bloat, or missing indexes. In a busy publishing install, the wp_posts and wp_postmeta tables are the usual suspects.

Reproduce the Failure: Dead Rows in InnoDB

If you use MySQL or MariaDB with InnoDB, dead rows accumulate after deletes and updates. They are not removed until the table is optimized. To see the current state:

SELECT table_name, data_free FROM information_schema.tables WHERE table_schema = 'your_db_name' ORDER BY data_free DESC LIMIT 10;

If data_free is large relative to the table size, the table has significant dead space. This slows full table scans and increases disk usage. Site Health will not flag it because the table is present and queryable.

To reclaim the space, run OPTIMIZE TABLE wp_postmeta; during a low-traffic window. Be aware that OPTIMIZE TABLE locks the table on some MySQL versions. For large tables, consider pt-online-schema-change from Percona Toolkit if you need to avoid downtime.

Reproduce the Failure: Missing Index on a High-Traffic Query

Use the slow query log to find queries that run frequently and take a long time. Enable it in your MySQL configuration:

slow_query_log = 1
long_query_time = 1
log_queries_not_using_indexes = 1

Then inspect the log for queries against wp_postmeta that filter on meta_key and meta_value without an index. A common offender is a query that looks up posts by a custom field. The default wp_postmeta table has an index on meta_key, but not on meta_value. If your theme or a plugin filters by meta_value, every query scans the entire table.

Add a targeted index only if the query pattern is stable and the table is large enough to justify it. For example:

ALTER TABLE wp_postmeta ADD INDEX meta_key_value (meta_key(191), meta_value(191));

Test the query plan before and after with EXPLAIN. An index that is never used is just write overhead.

Cron: Scheduled Is Not the Same as Executed

WordPress cron is a cooperative scheduler. It runs when someone visits the site. If your site has low traffic or a page cache that serves visitors without loading WordPress, cron events can sit in the queue for hours or days. Site Health checks that the cron system is not disabled, but it does not check that events are actually running on time.

Reproduce the Failure: Stuck Cron Events

List the current cron queue with WP-CLI:

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

If you see events with a next_run_relative of now or a past timestamp that never clear, cron is not keeping up. The usual causes are a page cache that bypasses WordPress, a missing system cron job, or a long-running event that blocks the queue.

The fix is to disable WordPress pseudo-cron and run it from the system scheduler:

define('DISABLE_WP_CRON', true);

Then add a system cron entry:

*/5 * * * * wp cron event run --due-now --path=/var/www/your-site --quiet

This guarantees that due events run every five minutes, regardless of traffic. Site Health will still show green either way, because the cron system is technically enabled.

What to Monitor Instead

Track the age of the oldest due cron event. If it exceeds 10 minutes, something is wrong. Also track the number of failed cron runs. A scheduled post that goes out late is a visible editorial failure, and it is almost never caught by Site Health.

Block Editor: HTTP 200 Is Not the Same as Usable

The block editor relies on a series of REST API requests. If a plugin or theme filter breaks the response shape, the editor can load with a blank canvas or missing blocks while the underlying HTTP requests return 200. Site Health does not test the block editor at all.

Reproduce the Failure: Empty Block List

Open the block editor and watch the network tab. Look for requests to /wp-json/wp/v2/block-types and /wp-json/wp/v2/block-patterns/patterns. If the response is 200 but the body is empty or missing expected fields, the editor will appear to load but will not show any blocks.

A common cause is a filter that modifies the REST response for block types. For example, a plugin that removes block types for certain user roles may accidentally remove all of them. To test from the command line:

wp eval 'print_r( WP_Block_Type_Registry::get_instance()->get_all_registered() );'

If the registry is empty, the editor has nothing to render. The fix is to find the filter that is emptying the registry and remove or correct it.

What to Monitor Instead

Add a smoke test that loads the block editor as an editor user and checks that the block list is non-empty. You can do this with a headless browser or a simple script that hits the REST endpoint and validates the JSON shape. This is the kind of check that catches failures before an editor sits down to write.

Site Health Score: A Number Without Context

The Site Health score is a weighted sum of individual test results. It is useful for spotting obvious misconfigurations, but it is not a performance metric. A site can score 100% while serving pages in three seconds and dropping scheduled posts. The score measures compliance with a checklist, not the health of the system under real use.

If you maintain a production install, treat Site Health as a starting point. Run it after major changes, but do not rely on it for ongoing monitoring. The checks that matter are the ones you write yourself, because they target the specific failure modes of your stack.

Building a Second Layer of Checks

The most useful checks are small, specific, and tied to a known failure mode. Here are three that cover the gaps described above.

Check 1: Object Cache Hit Rate

Run this every five minutes and alert if the hit rate drops below 80% for more than 15 minutes:

redis-cli INFO stats | awk -F: '/keyspace_hits/{hits=$2} /keyspace_misses/{misses=$2} END {if (hits+misses > 0) print hits/(hits+misses)*100}'

This is a single metric that tells you whether your cache is actually reducing database load.

Check 2: Oldest Due Cron Event

Run this every five minutes and alert if the oldest due event is older than 10 minutes:

wp cron event list --fields=next_run_relative --format=csv | tail -n +2 | sort | head -n 1

This catches the scheduled-post failure mode before an editor notices that their article did not go live.

Check 3: Block Editor Smoke Test

Run this every hour and alert if the block list is empty:

curl -s -H "Authorization: Bearer YOUR_APP_PASSWORD" https://your-site.com/wp-json/wp/v2/block-types | jq 'length'

If the length is zero, the editor is broken. This is a five-minute check that prevents a full day of editorial downtime.

When Site Health Is Actually Useful

Site Health is good at catching configuration errors that are easy to verify: missing PHP extensions, outdated WordPress core, insecure file permissions, and missing SSL certificates. These are real problems, and the tests for them are reliable. The issue is not that Site Health is useless; it is that it is incomplete. Use it for what it is good at, and build your own checks for everything else.

For a small publishing team, the highest-value checks are the ones that protect the editorial workflow. A scheduled post that goes out late is a visible failure. A block editor that loads blank is a visible failure. A slow page load is a visible failure. Site Health will not catch any of them, but a handful of targeted checks will.

FAQ

Why does my Site Health show green when my site is slow?

Site Health does not measure page load time, database query latency, or cache effectiveness. It checks that required components are present and configured to a minimum standard. A slow site can pass every test if the slowness comes from missing indexes, cache thrashing, or slow external requests.

How do I know if my object cache is actually working?

Check the cache backend directly. For Redis, run redis-cli INFO stats and look at keyspace_hits, keyspace_misses, and evicted_keys. A healthy cache has a high hit ratio and a low eviction rate. If evictions are high, the cache is not reducing database load.

Can I rely on WordPress cron for scheduled posts?

Only if your site has consistent traffic that loads WordPress on every visit. If you use a full-page cache or have low traffic, cron events can be delayed indefinitely. Disable WordPress pseudo-cron and run it from the system scheduler to guarantee that due events run on time.

What is the most common block editor failure that Site Health misses?

The most common failure is an empty block list caused by a filter that removes all registered block types. The editor loads, but no blocks are available. Site Health does not test the block editor, so the failure goes unnoticed until an editor tries to write.

Next Steps for Your Production Install

Start with the three checks above. They cover the failure modes that most often affect publishing teams: cache thrashing, stuck cron, and a broken block editor. Once those are in place, add checks for slow queries and table bloat. The goal is not to replace Site Health, but to fill the gaps it leaves open.

If you are dealing with a site that returns Nothing Found on new posts, the problem is usually a rewrite or query issue, not a Site Health failure. See What to Fix First When a New WordPress Site Says Nothing Found for a step-by-step diagnosis.

For ongoing monitoring, keep a log of every check that fires. The pattern of failures over time tells you more than any single alert. A cache that thrashes once a week is a different problem than one that thrashes every afternoon. The log is your evidence base for deciding what to fix next.

Server rack with network cables in a data center
Close-up of a database server hard drive activity light
Person typing on a laptop while reviewing code on a monitor

The Specific Problem With add_rewrite_rule() That flush_rewrite_rules() Doesn’t Fix

If you run a WordPress production install, you’ve probably registered a custom URL pattern with add_rewrite_rule(), called flush_rewrite_rules(), and expected the route to just work. Here’s the thing: flush_rewrite_rules() only rebuilds the rewrite_rules option from whatever rules are currently registered. It won’t fix a rule that points at the wrong query variable, a rule that collides with an existing internal rewrite, or a regex that never matches because it was built on a wrong assumption about how WordPress parses requests. For small-to-mid publishing teams, this stings because a broken rewrite rule usually looks like a caching issue, a permalink problem, or a theme conflict. The real failure is sitting in the wp_options table, inside the rewrite_rules array, or in the WP_Rewrite object before the request ever reaches the template loader.

This article is about the failure mode that survives a rewrite flush. It’s not a beginner’s guide to pretty permalinks. I’m assuming you already know WordPress stores compiled rewrite rules in the rewrite_rules option and that flush_rewrite_rules() deletes and rebuilds that option. The problem is narrower: a rule can be sitting in the database, visible in wp rewrite list, and still never fire because it was registered in a way that can’t match the request or can’t produce a valid query.

Server rack with network cables in a data center
Production rewrite failures are often misdiagnosed as server or cache issues.

What flush_rewrite_rules() Actually Does

flush_rewrite_rules() calls WP_Rewrite::flush_rules(). That method deletes the rewrite_rules option and then calls WP_Rewrite::wp_rewrite_rules() to rebuild the array from the rules that are currently registered. The key phrase is “currently registered.” If your rule was registered on init with a bad regex, a bad redirect, or a bad query string, the flush will happily write that bad rule back into the database. The flush doesn’t validate anything. It doesn’t test the rule against a sample URL. It doesn’t warn you that the rule will never match.

You can confirm this with a minimal plugin or mu-plugin:

add_action( 'init', function () {
    add_rewrite_rule( '^bad-route/([^/]+)/?$', 'index.php?bad_var=$matches[1]', 'top' );
} );
flush_rewrite_rules();

After running that code, the rule exists in the database. wp rewrite list shows it. But the rule won’t produce a valid query unless bad_var is a public query variable. If it’s not, WordPress won’t populate $wp_query->query_vars['bad_var'], and the request will fall through to a 404 or to a different rule. The flush did its job. The registration didn’t.

The Core Failure: A Rule That Cannot Resolve to a Query

WordPress rewrite rules are not standalone routes. They’re regex-to-query-string translations. A rule only works if the query string on the right side maps to a query variable that WordPress recognizes. Public query variables come from WP_Query, from registered post types and taxonomies, and from the query_vars filter. If you write a rule that points to index.php?custom_slug=$matches[1] but never register custom_slug as a public query var, the rule is dead on arrival.

This is the most common version of the problem in production. A developer adds a rule, flushes, tests one URL, sees a 404, and then starts disabling plugins or clearing caches. The actual fix is usually one of two lines:

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

Or, if the rule is meant to map to an existing post type or taxonomy, the query string should use the correct key, such as post_type, name, p, page_id, category_name, or a custom taxonomy query var.

You can inspect the current public query vars with WP-CLI:

wp eval 'var_dump( $wp_query->public_query_vars );'

If your custom key isn’t in that list, no amount of flushing will make the rule work.

Conflicting Rules and the ‘top’ vs ‘bottom’ Problem

Another failure that survives a flush is a rule that’s registered correctly but never reached because an earlier rule matches the same URL pattern. WordPress compiles rewrite rules into a large associative array. The order of that array isn’t the order in which you called add_rewrite_rule(). It’s determined by the internal rule groups: post rules, page rules, date rules, comment rules, search rules, author rules, and so on. Your custom rule is appended to the extra_rules_top or extra_rules group depending on the third argument.

If you pass 'top', the rule is added to extra_rules_top, which is placed before most internal rules. If you pass 'bottom', it’s added to extra_rules, which is placed after many internal rules. The problem is that “top” doesn’t mean “first.” It means “before the default internal groups.” A page rule or a post rule can still match first in some configurations, especially if your regex is too broad.

Consider this rule:

add_rewrite_rule( '^([^/]+)/?$', 'index.php?custom_page=$matches[1]', 'top' );

That regex matches every single-segment URL. It will intercept /about/, /contact/, and /2024/. If you have pages with those slugs, the page rewrite may still win because page rules are compiled into a specific position. The result is unpredictable unless you inspect the final array.

Use WP-CLI to see the actual order:

wp rewrite list --format=table

Look for your rule in the output. If it appears after a rule that matches the same URL, your rule will never fire. The fix is to make the regex more specific, change the rule group, or use a different mechanism such as a custom endpoint or a custom post type rewrite slug.

Person working on a laptop with code on the screen
Inspecting the compiled rewrite array is faster than guessing why a route 404s.

Regex Assumptions That Break After the Flush

WordPress rewrite rules are regular expressions. A rule that works in a local test can fail in production because the URL structure is different. The most common assumption is that the request path always starts with the site’s home path. On a subdirectory install, the request path passed to the rewrite engine is relative to the WordPress directory, not the domain root. A rule written for a root install won’t match on a subdirectory install unless the regex accounts for the base.

Another assumption is that query strings are not part of the rewrite match. They’re not. The rewrite engine matches against the path only. If your rule expects a query string parameter to be part of the match, it will never fire. The query string is parsed separately and is available in $_GET and in the query vars after the rewrite match.

A third assumption is that the regex delimiter and escaping are correct. WordPress uses # as the delimiter for some internal rules, but add_rewrite_rule() expects a regex without delimiters. If you include delimiters, the rule will be stored with them and won’t match. This is a silent failure: the rule is in the database, the flush succeeded, and the URL still 404s.

Test your regex outside WordPress first. Use preg_match() in a standalone PHP file or in wp shell:

wp shell
$pattern = '^bad-route/([^/]+)/?$';
$subject = 'bad-route/hello';
var_dump( preg_match( '#' . $pattern . '#', $subject, $matches ) );

If the test fails, the rule will fail in WordPress. The flush isn’t the problem.

When the Rule Is Correct but the Query Is Not

There’s a subtler failure mode. The rule matches, the query var is public, and the request still doesn’t load the expected content. This happens when the query string on the right side of the rule doesn’t produce a complete WP_Query. For example, a rule that maps to index.php?post_type=event&event_slug=$matches[1] will match, but WP_Query won’t know that event_slug is the slug for the event post type unless the post type is registered with 'query_var' => 'event_slug' and the rewrite slug is set correctly.

The same problem occurs with custom taxonomies. A rule that maps to index.php?event_type=$matches[1] won’t load a taxonomy archive unless event_type is the query var for a registered taxonomy. The rule is present, the flush is clean, and the request still falls through to the main query with no results.

You can debug this by hooking into request and logging the parsed query vars:

add_filter( 'request', function ( $query_vars ) {
    error_log( print_r( $query_vars, true ) );
    return $query_vars;
} );

Then request the URL and check the debug log. If your custom query var is missing or empty, the rule matched but the query didn’t resolve. The fix is in the post type or taxonomy registration, not in the rewrite rule.

Flush Timing and the Init Hook

Another specific problem is that flush_rewrite_rules() is often called at the wrong time. If you call it before your rules are registered, the flush writes an empty or incomplete rule set. If you call it on every init, you’re rebuilding the option on every request, which is a performance problem and can cause race conditions on high-traffic sites.

The correct pattern is to register rules on init and flush once, usually on plugin activation or theme switch. For a production install maintained by a small team, the safest approach is to use WP-CLI after deploying the rule change:

wp rewrite flush

That command regenerates the rules from the current codebase. It doesn’t fix a bad rule, but it does ensure that the flush happens after all rules are registered. If you’re debugging a rule that survives a flush, run the WP-CLI command and then immediately inspect the rule list. If the rule is present but the URL still fails, the problem is in the rule definition, not the flush.

Inspecting the Stored Rules Directly

The rewrite_rules option is a serialized array in wp_options. You can inspect it with SQL:

SELECT option_value FROM wp_options WHERE option_name = 'rewrite_rules';

The value is serialized. You can unserialize it with WP-CLI:

wp eval '$rules = get_option( "rewrite_rules" ); foreach ( $rules as $regex => $query ) { if ( false !== strpos( $regex, "bad-route" ) ) { echo $regex . " => " . $query . PHP_EOL; } }'

This shows you exactly what WordPress will match against. If the regex isn’t what you intended, the rule is broken. If the query string points to an unregistered query var, the rule is broken. If the rule is missing entirely, the flush didn’t run or the rule wasn’t registered at flush time.

Close-up of database code on a monitor
The rewrite_rules option is the source of truth for URL matching.

A Reproducible Failure Case

Here’s a complete failure case that you can reproduce on a clean WordPress install. It shows the exact problem: a rule that’s present after a flush but never fires.

add_action( 'init', function () {
    add_rewrite_rule( '^team/([^/]+)/?$', 'index.php?team_member=$matches[1]', 'top' );
} );

add_action( 'init', function () {
    flush_rewrite_rules();
}, 20 );

After loading the site once, run:

wp rewrite list --format=table | grep team

The rule appears. Now request /team/alice/. The result is a 404 or a fallback to the main query. The reason is that team_member isn’t a public query var. The rule matched, but WordPress couldn’t populate the query var, so the main query had no way to load a team member.

The fix is to register the query var:

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

Then flush again. The rule now works. The flush was never the problem. The rule was incomplete.

Why This Matters for Publishing Teams

Small-to-mid publishing teams often maintain their own production installs. They don’t have a dedicated platform team to debug rewrite rules. When a custom URL stops working after a deploy, the first instinct is to flush permalinks, clear the cache, or restore a backup. Those actions don’t fix a rule that was registered incorrectly. The result is lost time and a lingering fear that WordPress routing is fragile.

The durable fix is to treat rewrite rules as code, not configuration. Register them in version control. Test the regex before deploying. Verify that every query var on the right side of the rule is public. Inspect the compiled rule array after every flush. If a rule is present but not firing, the problem is in the rule definition, not in the flush.

For a deeper look at what to check when a new WordPress site returns nothing found, see What to Fix First When a New WordPress Site Says Nothing Found. That article covers the broader 404 diagnosis path, including permalink structure and server configuration, which are often confused with rewrite rule failures.

FAQ

Why does my rewrite rule show in wp rewrite list but still 404?

The rule is stored in the rewrite_rules option, but it may not match the request path, or it may match and produce a query string that WordPress cannot resolve. Check the regex against the actual request path and verify that every query var on the right side is public.

Does flush_rewrite_rules() fix a broken add_rewrite_rule() call?

No. The flush only regenerates the stored rules from the currently registered rules. If the registered rule has a bad regex, a bad query string, or an unregistered query var, the flush writes the same broken rule back into the database.

How can I tell if a query var is public in WordPress?

Run wp eval 'var_dump( $wp_query->public_query_vars );' or inspect the query_vars filter output. If your custom key isn’t in the list, WordPress won’t populate it from a rewrite rule.

What is the difference between ‘top’ and ‘bottom’ in add_rewrite_rule()?

The third argument controls whether the rule is added to extra_rules_top or extra_rules. “Top” places the rule before most internal rules, but it doesn’t guarantee first match. A broad regex can still be intercepted by a more specific internal rule. Inspect the final rule order with wp rewrite list.

Should I call flush_rewrite_rules() on every init?

No. That rebuilds the rewrite_rules option on every request, which is wasteful and can cause race conditions. Register rules on init and flush once on activation, theme switch, or after a deploy using wp rewrite flush.