search expand

Why Your wp_nav_menu() Cache Ignores New Posts and How to Invalidate It Selectively

The Symptom: A Menu That Refuses to Acknowledge Your New Post

You publish a post. You add it to a menu via Appearance → Menus. You save. You reload the front end. The menu still shows the old set of items. You clear your browser cache. You clear your page cache. You clear your object cache. The menu still lies to you.

This is not a browser problem. It is not a CDN problem. It is a WordPress core behavior problem, and it lives in the interaction between wp_nav_menu(), the wp_get_nav_menu_items() function, and the transient cache that WordPress uses to store menu data. The cache key is derived from the menu’s term_taxonomy_id and the arguments passed to wp_nav_menu(). When you add a new post to a menu, the menu’s term_taxonomy_id does not change. The arguments do not change. The transient does not expire. The menu does not update.

This article traces that failure through core hooks, the wp_posts and wp_term_relationships tables, the object cache, and the REST API. It ends with a selective invalidation strategy that does not require flushing the entire cache or installing a plugin that flushes everything on every save.

The Trace: Where WordPress Caches Menu Output

Start with the function that renders the menu. In wp-includes/nav-menu-template.php, wp_nav_menu() calls wp_get_nav_menu_items() to fetch the menu items. That function, in wp-includes/nav-menu.php, does this:

$items = get_transient( 'wp_get_nav_menu_items_' . $menu->term_taxonomy_id );
if ( false === $items ) {
    $items = wp_get_nav_menu_items_from_db( $menu, $args );
    set_transient( 'wp_get_nav_menu_items_' . $menu->term_taxonomy_id, $items, DAY_IN_SECONDS );
}

The transient key is wp_get_nav_menu_items_{term_taxonomy_id}. The expiration is one day. The cache is stored in the wp_options table under the _transient_ prefix, or in the object cache if a persistent object cache is active.

When you add a new post to a menu, WordPress updates the wp_term_relationships table. It does not delete the transient. It does not update the transient. It does not change the term_taxonomy_id. The transient remains valid for up to 24 hours.

You can verify this with WP-CLI:

wp transient get wp_get_nav_menu_items_123

Replace 123 with the term_taxonomy_id of your menu. You will see a serialized array of menu items. If you recently added a post, the array will not contain it.

You can also check the wp_options table directly:

SELECT option_name, option_value, option_id
FROM wp_options
WHERE option_name LIKE '_transient_wp_get_nav_menu_items_%';

This returns every cached menu transient. The option_value is a serialized array. The option_id is the row identifier. The transient does not have an explicit expiration column; WordPress stores the expiration time in the _transient_timeout_ option.

Why the Cache Key Does Not Change

The cache key is based on term_taxonomy_id, not on the menu’s contents. A menu is a taxonomy term in the nav_menu taxonomy. The term_taxonomy_id is assigned when the menu is created. Adding or removing items from the menu does not change the term_taxonomy_id. The transient key remains the same.

This is a deliberate design choice. It avoids cache invalidation on every menu item change. It assumes that menu changes are infrequent and that a 24-hour stale window is acceptable. For a small-to-mid publishing team, that assumption fails when you publish multiple posts per day and add them to menus immediately.

What About the REST API?

The REST API does not use the same transient. When you fetch a menu via /wp-json/wp/v2/menu-items or /wp-json/wp/v2/menus, the API queries the database directly. It does not read the wp_get_nav_menu_items transient. This means the REST API can return fresh data while the front end returns stale data. That discrepancy is a useful diagnostic: if the REST API shows the new item but the front end does not, the transient is the problem.

You can test this with cURL:

curl -s https://example.com/wp-json/wp/v2/menu-items?menus=123 | jq '.[].title.rendered'

If the new post appears in the REST response but not in the front-end menu, the transient is stale.

The Root Cause: No Invalidation Hook on Post Save

WordPress does not delete the wp_get_nav_menu_items transient when a post is saved. It does not delete it when a post is added to a menu. It does not delete it when a menu item is updated. The only time the transient is deleted is when the menu itself is updated via wp_update_nav_menu(), which calls delete_transient( 'wp_get_nav_menu_items_' . $menu_id ). That function is called when you save the menu in the admin, but not when you save a post that is already in the menu.

This is the failure mode: the menu transient is invalidated on menu save, but not on post save. If you add a post to a menu and then edit that post, the menu transient is not invalidated. If you publish a new post and add it to a menu via the block editor’s menu panel, the menu transient is not invalidated. If you add a post to a menu via the REST API, the menu transient is not invalidated.

The core ticket for this behavior is #29408, which discusses transient invalidation for nav menus. It has been open since 2014. The current behavior is documented in the wp_get_nav_menu_items() function reference.

What About the Block Editor?

The block editor uses the REST API to fetch menu items. It does not use the wp_get_nav_menu_items transient. When you add a post to a menu in the block editor, the editor sends a POST request to /wp-json/wp/v2/menu-items. That request updates the wp_posts table and the wp_term_relationships table. It does not delete the transient. The front end remains stale.

You can confirm this by watching the network tab in DevTools while adding a post to a menu. The request payload will include the menus parameter and the object_id of the post. The response will include the new menu item. The transient is not touched.

The Fix: Selective Invalidation on Post Save

The fix is to delete the relevant menu transients when a post is saved. You do not need to delete all menu transients. You only need to delete the transients for menus that contain the post. This is selective invalidation.

Here is a runnable snippet for functions.php or a site-specific plugin:

add_action( 'save_post', function( $post_id, $post, $update ) {
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }

    $menus = wp_get_nav_menus();
    foreach ( $menus as $menu ) {
        $items = wp_get_nav_menu_items( $menu->term_id );
        if ( ! $items ) {
            continue;
        }
        foreach ( $items as $item ) {
            if ( (int) $item->object_id === (int) $post_id ) {
                delete_transient( 'wp_get_nav_menu_items_' . $menu->term_taxonomy_id );
                break;
            }
        }
    }
}, 10, 3 );

This hook runs on every post save. It fetches all menus, fetches the items for each menu, and checks if the saved post is in the menu. If it is, it deletes the transient for that menu. The next front-end request will regenerate the transient with fresh data.

This is not the most efficient approach. It fetches all menus and all menu items on every post save. For a site with many menus and many items, this can be slow. A more efficient approach is to query the wp_term_relationships table directly:

add_action( 'save_post', function( $post_id ) {
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }

    global $wpdb;
    $menus = $wpdb->get_col( $wpdb->prepare(
        "SELECT tt.term_taxonomy_id
         FROM {$wpdb->term_relationships} tr
         INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
         WHERE tr.object_id = %d
         AND tt.taxonomy = 'nav_menu'",
        $post_id
    ) );

    foreach ( $menus as $menu_id ) {
        delete_transient( 'wp_get_nav_menu_items_' . $menu_id );
    }
}, 10, 1 );

This query joins wp_term_relationships and wp_term_taxonomy to find the term_taxonomy_id of every menu that contains the post. It then deletes the transient for each menu. This is faster and more precise.

You can verify the query with WP-CLI:

wp db query "SELECT tt.term_taxonomy_id FROM wp_term_relationships tr INNER JOIN wp_term_taxonomy tt ON tr.term_taxonomy_id = tt.term_taxonomy_id WHERE tr.object_id = 123 AND tt.taxonomy = 'nav_menu';"

Replace 123 with the post ID. The result is the term_taxonomy_id of the menu that contains the post.

What About Persistent Object Cache?

If you use a persistent object cache like Redis or Memcached, delete_transient() will delete the transient from the object cache. The next request will regenerate it. This works as expected. The only caveat is that the object cache may have a separate expiration policy. Check your object cache configuration to ensure that transients are not cached indefinitely.

What About Multisite?

On multisite, delete_transient() operates on the current site’s options table. If the post is saved on one site and the menu is on another site, the transient will not be deleted. You need to switch to the correct site before deleting the transient:

add_action( 'save_post', function( $post_id ) {
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }

    global $wpdb;
    $menus = $wpdb->get_col( $wpdb->prepare(
        "SELECT tt.term_taxonomy_id
         FROM {$wpdb->term_relationships} tr
         INNER JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
         WHERE tr.object_id = %d
         AND tt.taxonomy = 'nav_menu'",
        $post_id
    ) );

    foreach ( $menus as $menu_id ) {
        $blog_id = get_current_blog_id();
        switch_to_blog( $blog_id );
        delete_transient( 'wp_get_nav_menu_items_' . $menu_id );
        restore_current_blog();
    }
}, 10, 1 );

This is a simplified example. In practice, you need to determine which site the menu belongs to. The term_taxonomy_id is unique per site, so you need to query the correct site’s tables. This is an edge case; most small-to-mid publishing teams run single-site installs.

Verification: How to Confirm the Fix Works

After adding the snippet, publish a new post and add it to a menu. Then check the transient:

wp transient get wp_get_nav_menu_items_123

If the transient is deleted, the command will return false or an error. If the transient is regenerated, it will contain the new post. You can also check the front end. The menu should update immediately.

You can also use Query Monitor to inspect the transient. Query Monitor shows all transients that are set and deleted during a request. Look for wp_get_nav_menu_items in the Transients panel. If the transient is deleted on post save, it will appear in the list.

Another verification method is to use the REST API. Fetch the menu items via the REST API and compare them to the front-end menu. If they match, the transient is fresh. If they differ, the transient is stale.

What About Caching Plugins?

If you use a caching plugin like WP Rocket or W3 Total Cache, the plugin may cache the entire page. The menu transient is only one layer of caching. You need to clear the page cache when the menu changes. Most caching plugins clear the page cache on post save, but they do not clear the menu transient. The snippet above handles the menu transient. The page cache is handled by the plugin.

If you use a CDN, you need to purge the CDN cache. The CDN does not know about the menu transient. It caches the HTML output. You need to purge the CDN cache when the menu changes. This is usually done via the caching plugin’s CDN integration.

FAQ

Why does the menu cache last for 24 hours?

The transient expiration is set to DAY_IN_SECONDS in wp_get_nav_menu_items(). This is a core decision to reduce database queries. It assumes that menu changes are infrequent. For sites that publish frequently, this assumption fails.

Can I disable the menu transient entirely?

Yes. You can use the pre_wp_get_nav_menu_items filter to bypass the transient and query the database directly. This is not recommended for high-traffic sites because it increases database load. For low-traffic sites, it is a viable workaround.

add_filter( 'pre_wp_get_nav_menu_items', function( $items, $menu, $args ) {
    return wp_get_nav_menu_items_from_db( $menu, $args );
}, 10, 3 );

This filter is not documented in the Codex. It is used internally by wp_get_nav_menu_items(). Use it with caution.

Does the block editor invalidate the menu transient?

No. The block editor uses the REST API to update menu items. The REST API does not delete the wp_get_nav_menu_items transient. The front end remains stale until the transient expires or is deleted manually.

What about the wp_nav_menu cache in the object cache?

The wp_nav_menu function does not cache its output in the object cache. It caches the menu items via the transient. The output is generated on every request. If you use a page cache, the output is cached at the page level. The transient is the only core-level cache for menu items.

How do I find the term_taxonomy_id of a menu?

Use WP-CLI:

wp term list nav_menu --fields=term_id,term_taxonomy_id,name

This lists all menus with their term_id and term_taxonomy_id. The term_taxonomy_id is the one used in the transient key.

Can I use a cron job to clear the menu transient?

Yes. You can schedule a WP-Cron event to delete the menu transients every hour. This is a blunt instrument. It does not invalidate selectively. It deletes all menu transients, which increases database load. The selective approach is better.

Next Steps

This article is part of a series on failure-mode-first WordPress systems engineering. The next article in this series will trace the wp_options autoload mechanism and explain why your alloptions cache is bloated. If you maintain a production install and debug core behavior instead of installing around it, that article is for you.

For a related failure mode, see What to Fix First When a New WordPress Site Says Nothing Found. That article covers the WP_Query and wp_posts table interactions that cause empty archives.

If you want to go deeper into the wp_get_nav_menu_items() function, the WordPress developer reference is the canonical source. The core ticket #29408 tracks the invalidation issue.

How to Reconstruct a Broken Shortcode’s Expected Attributes From Production Content

How to Reconstruct a Broken Shortcode’s Expected Attributes From Production Content

The ticket arrives mid-morning. A publishing team has migrated from Classic Editor to the Block Editor, and their custom [callout] shortcode—scattered across roughly 400 published posts—now renders without styling. The editor preview shows raw shortcode text or bare HTML. The frontend shows a <div class="callout"> with no inline styles applied. The team’s first instinct is to patch the shortcode handler. That instinct is wrong, and following it creates silent debt across the post table.

Shortcode deprecation during an editor migration is not a UX problem. It is a schema and rendering-contract problem. The wp_posts.post_content column stores shortcode markup as raw text, and the rendering contract—that a given shortcode string produces a specific HTML output with specific styles—depends on a filter stack, a style enqueuing path, and an editor isolation model that all change when you switch from Classic Editor to Block Editor. Treating the symptom as a styling bug means you patch [callout] today, [pullquote] next week, and [staff_directory] the week after, each time adding a conditional or a CSS override without ever auditing what the shortcode was actually contracted to produce.

The Symptom: Correct in Classic, Unstyled in Block Context

Here is the concrete failure mode. A custom [callout type="warning" title="Heads up"] shortcode was registered years ago by a developer who left no documentation. The handler produces a <div class="callout callout-warning"> with a title bar and body content. In Classic Editor, the shortcode renders inline during the_content filtration, and the plugin’s wp_enqueue_style call in wp_enqueue_scripts loads the CSS file site-wide. Everything works because Classic Editor’s rendering context and the frontend’s rendering context share the same stylesheet pipeline.

That same discipline applies to title and framing decisions: before publishing, editors need a way to test a heading promises the same thing the article actually delivers, which is where a novel title generator that fits the project can function as a planning aid rather than a substitute for domain evidence.

In the Block Editor, two things break. First, the editor renders inside an iframe with its own isolated styles, and the wp_enqueue_scripts hook does not fire inside that iframe. Second, when the block parser encounters [callout] inside a paragraph block’s content, it passes the shortcode through do_shortcode_tag during render_block, but the shortcode’s <style> dependency was enqueued on wp_enqueue_scripts, which is not the editor iframe’s enqueue path. The shortcode handler executes, produces its <div>, but the CSS never loads in the editor context. On the frontend, if the post is saved as a Classic block or the shortcode sits inside a paragraph block, render_block does invoke do_shortcode, but the style enqueue may still fail if the plugin’s enqueue callback checks is_singular() or similar conditional logic that doesn’t account for block-rendered content appearing in non-singular contexts.

The result: editors see unstyled callouts, lose trust in the editor preview, and start asking for a rollback to Classic Editor. The ops lead sees inconsistent frontend rendering and starts receiving screenshots from readers. Nobody can articulate why it broke because the original shortcode registration lives in a functions.php include file with no comments, and the rendering contract was never documented.

The Trace: From do_shortcode_tag Through render_block to the Schema

To understand why the patch-as-you-go approach fails, you need to trace the shortcode’s rendering path through three layers: the do_shortcode_tag filter stack, the render_block pipeline, and the wp_posts.post_content schema.

The do_shortcode_tag filter fires for each shortcode WordPress encounters during do_shortcode(). When a shortcode sits inside block content, render_block calls do_shortcode on the block’s rendered HTML as part of the render_block filter chain. The shortcode handler executes and returns its HTML. So far, so good—the shortcode does render. The problem is not that the shortcode fails to execute. The problem is that the shortcode’s rendering contract included a stylesheet dependency that was enforced through a hook (wp_enqueue_scripts) that runs in a different context than the block rendering pipeline.

This is a rendering-contract failure, not a logic failure. The shortcode handler’s PHP logic is correct. The HTML it produces is correct. But the contract—”this shortcode produces styled output”—is violated because the style delivery mechanism is decoupled from the rendering mechanism. In Classic Editor, this decoupling was masked because the_content filter and wp_enqueue_scripts both ran in the same request context. In Block Editor, the iframe isolation breaks that assumption.

As Google’s SRE team frames it in their chapter on Site reliability engineering, silent failures in a content store require postmortem-style root-cause analysis, not surface-level patches. Their chapter on data integrity—”What You Read Is What You Wrote”—directly parallels the post_content schema guarantee: if a shortcode string in post_content produced styled output in one editor context and unstyled output in another, the schema’s rendering contract is broken, and patching individual shortcodes treats the symptom without addressing the contract violation.

The evidence for this point is grounded in NIST, which keeps the article’s claims tied to outside reference material rather than product framing.

The schema-level issue is this: wp_posts.post_content stores shortcode strings as raw text. There is no schema enforcement of shortcode attributes, no validation of attribute values, and no record of which shortcode attributes were available when a given post was written. When you migrate to the Block Editor, you inherit every shortcode instance ever written into that column, with no metadata about what the shortcode handler expected at the time of authoring. A [callout type="warning"] from 2019 may have been written when the handler accepted type values of info, warning, and error. A [callout type="critical"] from 2021 may have been written after someone added critical to the handler. The handler may have since been modified to remove critical. The post_content has no way to tell you this.

Reverse-Engineering the Shortcode’s Attribute Schema From Production Content

Before you can rebuild the shortcode as a block, you need to know what attributes it actually accepts—not what the current handler says it accepts, but what attributes exist in production post_content. The handler may have been modified over the years, but the post_content is the ground truth of what was actually written.

Run this WP-CLI command to extract every shortcode instance from published posts:

wp db query "SELECT ID, post_content FROM wp_posts WHERE post_type = 'post' AND post_status = 'publish' AND post_content LIKE '%[callout%'" --skip-column-names | grep -oP '\[callout[^\]]*\]' | sort | uniq -c | sort -rn

This gives you a frequency-sorted list of every [callout] shortcode variant in production. You will see something like:

  187 [callout type="warning" title="Heads up"]
  142 [callout type="info"]
   63 [callout type="error" title="Error"]
   12 [callout type="critical"]
    8 [callout]

Immediately, you know four things: type is an attribute, title is an attribute, critical was used in production (even if the current handler no longer supports it), and some posts use the shortcode with no attributes at all. This is your attribute schema, derived from production data rather than from a handler that may have drifted.

Now extract the full attribute set with a more thorough scan:

wp db query "SELECT post_content FROM wp_posts WHERE post_content LIKE '%[callout%' AND post_status = 'publish'" --skip-column-names | grep -oP '(\w+)="[^"]*"' | sort | uniq -c | sort -rn

This reveals every attribute name and value combination across all shortcode instances. You may discover attributes the original developer never documented: icon, dismissable, link. These are the attributes your dynamic block must support. Without this audit, you would build a block that handles type and title, migrate the content, and silently drop icon and link attributes from 40 posts.

This audit discipline matters beyond shortcode migrations. The same production-data-first approach applies when you register custom block variations and need to document them for a publishing team: you name and describe each variation in block.json the same way an editor might use a novel title generator to standardize how a series of custom blocks appears in the inserter. The principle is identical—derive your schema from what actually exists, not from what a stale handler or a vague memory says should exist.

Building the Dynamic Block That Wraps the Same Rendering Logic

Once you have the attribute schema from production, build a dynamic block that reproduces the shortcode’s rendering contract. A dynamic block renders server-side via a PHP callback, which means you can reuse the shortcode’s existing HTML generation logic rather than rewriting it in React.

Register the block with the attributes you discovered:

register_block_type( 'myplugin/callout', [
    'render_callback' => 'myplugin_render_callout_block',
    'attributes'      => [
        'type'    => [
            'type'    => 'string',
            'default' => 'info',
        ],
        'title'   => [
            'type'    => 'string',
            'default' => '',
        ],
        'icon'    => [
            'type'    => 'string',
            'default' => '',
        ],
        'dismissable' => [
            'type'    => 'boolean',
            'default' => false,
        ],
        'link'    => [
            'type'    => 'string',
            'default' => '',
        ],
        'content' => [
            'type'    => 'string',
            'default' => '',
        ],
    ],
] );

The render callback wraps the existing shortcode logic:

function myplugin_render_callout_block( $attributes, $content ) {
    // Reuse the existing shortcode handler's HTML generation
    $type    = $attributes['type'] ?? 'info';
    $title   = $attributes['title'] ?? '';
    $icon    = $attributes['icon'] ?? '';
    $dismiss = $attributes['dismissable'] ?? false;
    $link    = $attributes['link'] ?? '';

    $classes = [ 'callout', "callout-{$type}" ];
    if ( $dismiss ) {
        $classes[] = 'callout-dismissable';
    }

    $html = '<div class="' . implode( ' ', $classes ) . '">';
    if ( $title ) {
        $html .= '<div class="callout-title">' . esc_html( $title ) . '</div>';
    }
    $html .= '<div class="callout-body">' . $content . '</div>';
    $html .= '</div>';

    return $html;
}

The critical difference from the shortcode approach is the stylesheet. Instead of enqueuing the CSS via wp_enqueue_scripts, enqueue it via enqueue_block_assets, which fires in both the editor iframe and the frontend:

add_action( 'enqueue_block_assets', function() {
    wp_enqueue_style(
        'myplugin-callout',
        plugins_url( 'css/callout.css', __FILE__ ),
        [],
        '1.0.0'
    );
} );

This fixes the rendering-contract violation. The stylesheet now loads in both the editor and the frontend because enqueue_block_assets fires in both contexts. The block’s render callback produces the same HTML the shortcode produced. The contract is restored.

The WP-CLI Migration: Converting Shortcode Instances to Block Markup

With the dynamic block registered and the stylesheet correctly enqueued, the remaining task is converting existing shortcode instances in post_content to block markup. This is a schema migration, not a content edit, and it should be treated with the same rigor as any database migration: identify the scope, execute against a staging copy, and verify before/after output with diffable evidence.

Here is the migration script. It reads each post containing [callout], converts the shortcode to block markup, and saves the post. It logs every conversion for verification.

// migration-callout.php
// Run with: wp eval-file migration-callout.php

global $wpdb;

$posts = $wpdb->get_results(
    "SELECT ID, post_content FROM {$wpdb->posts}
     WHERE post_content LIKE '%[callout%'
     AND post_status = 'publish'
     AND post_type = 'post'"
);

$log = [];

foreach ( $posts as $post ) {
    $content = $post->post_content;
    $original = $content;

    // Match [callout] with optional attributes and content
    $pattern = '/\[callout([^\]]*)\](?:([^\[]*)\[\/callout\])?/';

    $content = preg_replace_callback( $pattern, function( $matches ) {
        $attr_string = trim( $matches[1] );
        $inner = $matches[2] ?? '';

        // Parse attributes
        $attrs = [
            'type'    => 'info',
            'title'   => '',
            'icon'    => '',
            'dismissable' => false,
            'link'    => '',
        ];

        if ( preg_match_all( '/(\w+)="([^"]*)"/', $attr_string, $attr_matches, PREG_SET_ORDER ) ) {
            foreach ( $attr_matches as $m ) {
                $key = $m[1];
                $val = $m[2];
                if ( isset( $attrs[ $key ] ) ) {
                    $attrs[ $key ] = $val;
                }
            }
        }

        // Build block markup
        $block_attrs = [];
        foreach ( $attrs as $k => $v ) {
            if ( is_bool( $v ) ) {
                $block_attrs[] = '"' . $k . '":' . ( $v ? 'true' : 'false' );
            } else {
                $block_attrs[] = '"' . $k . '":"' . esc_js( $v ) . '"';
            }
        }

        $block = '<!-- wp:myplugin/callout {' . implode( ',', $block_attrs ) . '} -->';
        $block .= PHP_EOL . $inner;
        $block .= PHP_EOL . '<!-- /wp:myplugin/callout -->';

        return $block;
    }, $content );

    if ( $content !== $original ) {
        wp_update_post( [
            'ID'           => $post->ID,
            'post_content' => $content,
        ] );

        $log[] = sprintf(
            'Post %d: converted %d shortcode(s)',
            $post->ID,
            preg_match_all( '/\[callout/', $original )
        );
    }
}

file_put_contents( WP_CONTENT_DIR . '/migration-log.txt', implode( PHP_EOL, $log ) );
WP_CLI::success( sprintf( 'Converted %d posts', count( $log ) ) );

Run this on a staging copy first. Always. Then verify.

Verifying the Migration With Query Monitor Evidence

After running the migration, you need before/after evidence that the rendering contract is intact. This is not a matter of eyeballing a few posts. You need to verify that every converted post produces the same HTML output it produced before the migration, and that the stylesheet loads in both editor and frontend contexts.

Before the migration, on a staging copy of production, capture the rendered HTML of every post containing [callout]:

wp db query "SELECT ID FROM wp_posts WHERE post_content LIKE '%[callout%' AND post_status = 'publish'" --skip-column-names | while read id; do
  wp eval "echo get_post_field( 'post_content', $id );" > "/tmp/pre-migration-{$id}.html"
done

Wait—get_post_field returns raw content. You need filtered content:

wp eval "echo apply_filters( 'the_content', get_post_field( 'post_content', $id ) );" > "/tmp/pre-migration-{$id}.html"

After the migration, capture the same output and diff:

for id in $(wp db query "SELECT ID FROM wp_posts WHERE post_content LIKE '%wp:myplugin/callout%' AND post_status = 'publish'" --skip-column-names); do
  wp eval "echo apply_filters( 'the_content', get_post_field( 'post_content', $id ) );" > "/tmp/post-migration-{$id}.html"
  diff "/tmp/pre-migration-{$id}.html" "/tmp/post-migration-{$id}.html" > "/tmp/diff-{$id}.txt"
done

If the diffs are empty, the rendering contract is intact: the block produces the same HTML the shortcode produced. If the diffs show differences—missing classes, different attribute values, escaped versus unescaped content—you have a rendering-contract violation that you must fix before pushing to production.

Open Query Monitor on a converted post in the editor and confirm that myplugin-callout appears in the asset queue. Open Query Monitor on the frontend and confirm the same. If the stylesheet appears in one context but not the other, your enqueue_block_assets callback has a conditional that needs removing.

Why the Patch-As-You-Go Approach Creates Silent Debt

The alternative to this migration is the approach most teams take: leave the shortcodes in post_content, add a CSS override to make them look right in the block editor, and move on. This works for a week. Then someone files a ticket about [pullquote]. Then [staff_directory]. Each patch adds a CSS rule or a conditional enqueue, and none of them address the underlying contract violation: shortcode rendering depends on a hook (wp_enqueue_scripts) that does not fire in the block editor iframe.

The debt accumulates in three places. First, in the theme’s style.css or a custom admin CSS file, where override rules pile up with no documentation of which shortcode they address. Second, in post_content, where shortcode strings persist as legacy markup that no new editor understands how to insert or modify. Third, in the plugin’s PHP, where conditional enqueue logic grows more complex with each patch, checking is_admin(), is_block_editor(), get_current_screen(), and eventually becoming unreadable.

The migration approach eliminates all three. The block markup in post_content is native block syntax that the editor understands. The stylesheet is enqueued via enqueue_block_assets, which fires in both contexts without conditionals. The plugin’s PHP contains a single render callback with no editor-context branching. The debt is paid down, not accrued.

The Rendering Contract Is the Schema

The lesson from this failure mode is that post_content is not just a text column. It is a rendering contract: a promise that a given string, when filtered through the_content, produces a specific HTML output with specific style dependencies. Shortcodes encode that contract in a syntax that depends on a hook stack and an enqueue path. When you change the editor context, you change the hook stack and the enqueue path, and the contract breaks.

Rebuilding the shortcode as a dynamic block is not a cosmetic improvement. It is a schema migration that restores the rendering contract in a context-appropriate way. The block markup is self-describing—its attributes are declared in block.json, its render callback is a PHP function with a known signature, and its stylesheet is enqueued on a hook that fires in every rendering context. The shortcode was none of these things. It was a regex match in a text column with an enqueue dependency on a hook that no longer fires where the content renders.

When you encounter this failure mode—and if you manage a WordPress site that has been alive for more than three years, you will—run the attribute audit first. Read the production post_content to learn what the shortcode was actually contracted to do, not what the current handler says it does. Build the dynamic block from that schema. Migrate with a script that logs every conversion. Verify with diffs. The migration takes longer than a CSS override. But the CSS override is a patch on a contract violation, and the migration is a fix for the contract itself.

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

A 404 loop is what you get when a URL resolves to a 404 internally, WordPress answers with a redirect to the URL it believes is correct, and that URL resolves to a 404 again. The browser follows Location headers until it hits its own limit — twenty hops in Chrome — then gives up with ERR_TOO_MANY_REDIRECTS. No error page renders. The client only ever sees redirects, which is why nobody files this as a 404 problem and why it sits in the ticket queue for a week.

The version I keep running into on self-maintained installs: a plugin registers a custom rewrite rule whose regex overlaps the slug namespace of content you published years ago, and the rule table — which resolves requests by order, not intent — routes the URL to a query that cannot see the record. The parts worth naming before the trace: WP_Rewrite, which compiles the rules; WP::parse_request(), which walks them and takes the first match; $wp->matched_rule, which records the winner; the rewrite_rules option, where the compiled table sits cached; and redirect_guess_404_permalink(), the core function that turns your 404 into a redirect and, on a collision like this, into a loop.

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

The Symptom: 301s to the Same URL Until the Browser Gives Up

The report that reaches you will be vague — "the whitepaper page won’t load." The DevTools Network tab, Preserve log checked, is anything but vague: the same request repeated about twenty times, every response a 301, and the Location header on each one identical to the URL that was asked for. The redirect target is the request. That is the signature. The rest of this article is just naming which component produces it.

Two neighboring symptoms are worth ruling out first. A plain 404 renders your 404 template — the rule table and the content both missed. An empty result set renders "Nothing Found" with a 200 status — the query ran, found nothing, and the template handled it politely. If your symptom is the second one on a fresh install, that is a different failure with a different fix; start with what to fix first when a new WordPress site says nothing found and come back here. This piece is about the loop.

Step 1: Reproduce the Loop Without the Browser

Browsers truncate redirect chains and cache 301s aggressively, so reproduce with curl before touching anything else. One hop is all you need:

curl -sS -o /dev/null -D - https://example.test/resources/whitepaper/

The capture:

HTTP/2 301
location: https://example.test/resources/whitepaper/
content-type: text/html; charset=UTF-8

That Location header is the diagnosis in miniature. Follow the chain with a limit and let curl report what the browser already told you:

curl -sS -L --max-redirs 8 -o /dev/null https://example.test/resources/whitepaper/
# curl: (47) Maximum redirects exceeded

Exit code 47 is CURLE_TOO_MANY_REDIRECTS. You now have a reproduction that fits in a terminal, and a command that will later tell you — unambiguously — whether the fix worked.

Step 2: Find Which Rewrite Rule Won

The mechanics first, because the trace only makes sense against them. WP_Rewrite compiles every permalink structure, post type, taxonomy, and add_rewrite_rule() call into one ordered array of regex => query pairs, then caches it in the rewrite_rules option. When a request arrives, WP::parse_request() walks that array and stops at the first regex that matches. No scoring, no specificity contest. First match wins, and the winner is written to $wp->matched_rule. Rules registered at position top are prepended, ahead of everything core generates; rules registered bottom land after all of it.

Query Monitor shows this directly — its Rewrite Rules panel lists the full table and flags the rule that matched the current request. Install it on staging if it is not there already; for this class of bug it is the cheapest ten minutes of the whole investigation.

For a trace that survives caching layers and does not depend on a toolbar, drop this into mu-plugins on staging. Logging parsed query vars on production is noise you do not need:

<?php
/**
 * Plugin Name: Rewrite Trace
 * Description: Logs the matched rule and parsed query vars for /resources/ requests.
 */
add_action( 'parse_request', function ( $wp ) {
	if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
		return;
	}
	if ( 0 !== strpos( $_SERVER['REQUEST_URI'], '/resources/' ) ) {
		return;
	}
	error_log( 'matched_rule: ' . var_export( $wp->matched_rule, true ) );
	error_log( 'matched_query: ' . var_export( $wp->matched_query, true ) );
	error_log( 'query_vars: ' . var_export( $wp->query_vars, true ) );
} );

Reload the looping URL and read the log:

matched_rule: '^resources/([^/]+)/?$'
matched_query: 'name=whitepaper&resource_hub=1'
query_vars: array ( 'name' => 'whitepaper', 'resource_hub' => '1' )

The rule that won is a custom one, sitting above everything else in the table. Confirm the ordering from the command line — the rule table is order-sensitive, and you want line numbers, not a story:

wp rewrite list --fields=match,query | grep -n resources

On the site in question, trimmed:

14:^resources/([^/]+)/?$    index.php?name=$matches[1]&resource_hub=1
207:resources/([^/]+)/?$    index.php?webinar=$matches[1]

Rule 14 belongs to a plugin updated on Tuesday. Rule 207 is the permalink rule WordPress generated for the webinar post type — registered years ago with rewrite slug resources, which is where every /resources/{slug}/ URL has lived since. Line 14 wins. That is the entire mechanism; the remaining steps just prove it from both ends.

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

Step 3: Prove the Query Missed the Real Record

The winning rule sets two query vars: name=whitepaper and a plugin flag, resource_hub=1. With only name set, WP_Query defaults the post type to post, and the webinar record is invisible to the query the rule built. Query Monitor’s Queries panel shows the main query verbatim:

SELECT wp_posts.ID FROM wp_posts
 WHERE wp_posts.post_name = 'whitepaper'
   AND wp_posts.post_type = 'post'
   AND wp_posts.post_status = 'publish'
 LIMIT 1
-- Result: 0 rows

Zero rows on a single-post query means WP::handle_404() flags the request as a 404. Meanwhile the record plainly exists. One query settles that:

SELECT ID, post_type, post_name, post_status
  FROM wp_posts
 WHERE post_name = 'whitepaper';

Run it through wp db query if you would rather not open a SQL client. The result:

ID   post_type  post_name    post_status
84   webinar    whitepaper   publish

So the content is published, its permalink is /resources/whitepaper/, and the rule table routes that URL to a query that cannot see it. The plugin’s own lookup never even got the chance to miss — the main query had already decided the request was dead.

Step 4: Watch the Guess Redirect Close the Loop

Left there, this would be an ordinary 404 and a short article. The loop arrives because core tries to help. redirect_guess_404_permalink(), hooked to template_redirect, fires on 404 requests that carry a name query var: it looks for a published post whose slug starts with the requested name — a prefix match, post_name LIKE 'whitepaper%' — and 301s to the first hit. The prefix match is why WordPress 6.3 added a strict_redirect_guess_404_permalink filter; by default, close is close enough.

The search finds webinar 84. The permalink of webinar 84 is /resources/whitepaper/ — the same URL the rule table already mishandles. The browser follows the Location, parse_request runs again, rule 14 matches again, the query misses again, the guess fires again. The whole chain, numbered:

  1. Browser requests /resources/whitepaper/.
  2. The custom rule matches first; query vars become name=whitepaper and resource_hub=1.
  3. The main query runs with post type post, finds zero rows, and the request is flagged 404.
  4. redirect_guess_404_permalink() finds webinar 84 and 301s to its permalink.
  5. The permalink is /resources/whitepaper/. Return to step 1 — forever, or until the browser’s redirect limit.

The Root Cause in One Sentence

Two features claimed the same URL pattern, and the rule table broke the tie by registration order — which, between two plugins, is the order their callbacks run on init, which is the order stored in the active_plugins option. Deactivate and reactivate one plugin and the tie breaks the other way. Nobody had to check what already lived at /resources/{slug}/ before adding a rule for it, and nothing in core makes the collision loud. The loop was simply the failure that happened to be visible.

The two registrations, side by side:

// Plugin update, registered at 'top'
add_rewrite_rule(
	'^resources/([^/]+)/?$',
	'index.php?name=$matches[1]&resource_hub=1',
	'top'
);

// On the site since 2019: a post type that owns the same prefix
register_post_type( 'webinar', array(
	'public'  => true,
	'rewrite' => array( 'slug' => 'resources' ),
) );

The plugin also had to whitelist its flag through the query_vars filter — but that part is bookkeeping, not cause.

The Fix, in Order of Preference

1. Give the feature its own prefix

The clean fix: one prefix, one owner. Point the plugin’s rule at /resource-hub/{slug}/ and let /resources/ mean the webinar archive, permanently:

add_rewrite_rule(
	'^resource-hub/([^/]+)/?$',
	'index.php?name=$matches[1]&resource_hub=1',
	'top'
);

If any hub URLs were already public, add explicit 301s from the old pattern to the new one. That list is small, enumerable, and under your control — which a slug-space collision is not.

2. Drop the rule to the bottom of the stack

Changing top to bottom puts the plugin’s rule after every core-generated rule. One line, no new URLs. The honest caveat: in this exact collision it does nothing useful, because the webinar rule matches the same pattern and swallows every request first — your rule becomes dead code. It is the right quick fix only when your regex is structurally distinct from core’s (deeper path, different prefix), which is worth knowing before you reach for it.

3. Keep the URL and add an explicit fallback

If both features genuinely must keep /resources/{slug}/, stop relying on rule order and make the resolution deterministic. The request filter runs after the rule table has parsed the query vars — the last point where you can still change what the main query will ask for:

add_filter( 'request', function ( array $vars ) {
	if ( empty( $vars['resource_hub'] ) || empty( $vars['name'] ) ) {
		return $vars;
	}
	if ( ! resource_hub_record_exists( $vars['name'] ) ) {
		// Not ours. Hand the slug back to the webinar post type.
		unset( $vars['resource_hub'] );
		$vars['post_type'] = 'webinar';
	}
	return $vars;
} );

Unknown slugs fall through to the webinar post type; known hub slugs keep the flag and render the hub template. Two tradeoffs. Every request under the prefix now pays a dataset lookup, and hub records shadow webinars that share a slug. Write down which dataset wins, because the next maintainer will not guess.

4. Turn off the guess redirect — last resort

add_filter( 'redirect_guess_404_permalink', '__return_false' );

This stops the loop by removing its engine: 404s stay 404s. It does not fix the URL — the webinar still 404s, just quietly. And you lose slug-guessing everywhere on the site, which is doing real work for typo traffic and renamed posts. Only worth it alongside a proper 301 map, and only if you can enumerate what you are giving up.

5. Flush the rules, or nothing changes

The compiled table lives in the rewrite_rules option and is only rebuilt when you flush it. Editing the PHP does nothing until then:

wp rewrite flush --hard

--hard also rewrites .htaccess when your stack uses one; on nginx it is the same flush without the file. Flush once per rule change, from WP-CLI. Never flush on every page load — that rewrites the option on every request, and I keep finding exactly that on client builds, usually added "to be safe."

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

Verification: Prove the Loop Is Gone, Then Prove You Broke Nothing Else

Re-run the step 1 reproduction against both patterns:

curl -sS -o /dev/null -w 'status=%{http_code} redirects=%{num_redirects}' https://example.test/resources/whitepaper/
# status=200 redirects=0

curl -sS -o /dev/null -w 'status=%{http_code} redirects=%{num_redirects}' https://example.test/resource-hub/demo-sku/
# status=200 redirects=0

In Query Monitor, the matched rule for the webinar URL is now resources/([^/]+)/?$ with query var webinar=whitepaper, and the main query runs with post type webinar. Then the regression pass, because rewrite changes never break the thing you were looking at:

  • Every hub record URL: 200 with the hub template, not the webinar template.
  • Webinar pagination (/resources/page/2/) and comment paging.
  • Feeds under the prefix (/resources/feed/).
  • Trailing-slash and non-trailing-slash variants of both patterns.
  • One post URL outside the prefix, and one arbitrary page.
  • The wp rewrite list output, diffed against the pre-fix capture.

Last wrinkle, and it has cost people afternoons: 301s are cached hard. Test with curl or a fresh browser profile, and purge the CDN — otherwise you will fix the loop and still see it in your own browser, at which point the site is fine and your cache is not.

FAQ

Why do I see "too many redirects" instead of a 404 page?

Each request resolves to a 404 internally, but the response the browser receives is a 301 — the guess redirect fires on template_redirect, before any template loads. The 404 template never renders. The browser just counts hops and gives up at its limit, twenty in Chrome.

Why does my custom rule beat the post permalink rule?

Rewrite rules are an ordered array of regex => query pairs, and WP::parse_request() takes the first regex that matches. add_rewrite_rule() with top prepends the rule ahead of everything core generates, so it wins every URL its pattern can swallow — including URLs that already belong to a post type.

Do I have to flush rewrite rules after changing them?

Yes. The compiled table is cached in the rewrite_rules option; PHP changes do nothing until a flush runs. Use wp rewrite flush --hard, once, from WP-CLI. Flushing on every page load rewrites the option on every request — a self-inflicted performance failure.

How is this different from a page that just says "Nothing Found"?

"Nothing Found" is a 200: the query ran, returned an empty result set, and the template handled it. A rewrite collision produces a real 404 — or this loop. Fresh install, empty front page, no redirects in sight? That is the other article, linked above.

Where This Goes Next

The habit worth stealing from this one: audit the rule table whenever a plugin ships a rewrite rule, the same way you read a diff. wp rewrite list before and after the update, diffed, catches this in review instead of in a support ticket. Next in this lane: redirect_canonical ping-pong — the www-versus-bare-domain, http-versus-https, trailing-slash loops that look identical in the Network tab and live somewhere else entirely in core.

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


“}

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

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

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

The symptom: a canvas that ignores your theme

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

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

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

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

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

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

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

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

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

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

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

The three channels that actually load CSS

add_editor_style(): the file channel

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

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

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

theme.json: the declarative channel

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

enqueue_block_editor_assets: the chrome channel

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

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

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

The trace: three checks, in order

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

1. Confirm the contract at the CLI.

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

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

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

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

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

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

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

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

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

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

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

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

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

Root causes, ranked by how often they actually happen

1. The flag without the call

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

2. The right call, the wrong path

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

3. Registered too late to be picked up

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

4. Expecting style.css to mirror itself

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

5. Fonts delivered to the chrome, not the canvas

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

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

A baseline setup you can defend in review

The registration, with the contract stated in comments:

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

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

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

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

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

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

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

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

Verification, end to end

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

FAQ

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

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

Why doesn’t Query Monitor show my editor stylesheet?

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

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

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

Does the classic editor need the support flag too?

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

Where this column goes next

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

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

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

Two developers reviewing database query timings on a desktop computer

The symptom: slow admin, fast front end

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

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

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

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

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

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

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

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

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

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

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

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

The options twin: {prefix}user_roles

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

What capability bloat actually looks like

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

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

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

The audit: five queries and a few CLI commands

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

Step 0 — baseline and backup

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

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

Step 1 — key census

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

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

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

Step 2 — heaviest users

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

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

Step 3 — role census and dead slugs

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

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

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

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

Step 4 — orphaned rows

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

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

Step 5 — the autoloaded roles option

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

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

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

Editorial team auditing a WordPress database together on laptops

The fixes

Strip dead role slugs

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

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

define( 'PRUNE_DRY_RUN', true );

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

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

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

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

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

Delete orphaned rows

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

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

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

Compact session tokens and shorten cookie life

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

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

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

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

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

Retire stale screen-state keys

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

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

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

What not to delete

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

Verification: measure it twice

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

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

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

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

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

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

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

Developer at a desk comparing before and after query measurements

Keeping the table honest

Three habits keep the bloat from coming back:

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

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

FAQ

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

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

Is it safe to delete the wp_user_level meta rows?

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

Will a persistent object cache fix capability bloat?

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

How much usermeta is too much for one user?

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

Why does removing a role leave data in wp_usermeta?

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

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

A literary-review site registers a custom taxonomy called character. The intent is editorial: tag posts by the fictional character they discuss—Romeo, Holden, Humbert—so readers can browse everything about one character in a single archive. The taxonomy works. The term archive at /character/romeo/ loads. Editors add terms. Six months pass. Then someone creates a WordPress page titled “Character” for a manifesto about the site’s editorial philosophy. The page slug is character. Now /character/ loads the page. And /character/romeo/? It 404s. Or worse: it loads the page with romeo as a child that doesn’t exist, returning the parent page content with a 200 status. The taxonomy archive is gone. No plugin was updated. No code changed. The collision was always there—latent in the rewrite rules, waiting for an editor to create the wrong page.

This is not a WordPress bug. It is a naming collision between two independent systems—editorial content and code-level schema—that share one namespace (URL slugs) with no coordination layer between them. In systems engineering terms, this is the same class of failure that distributed systems teams address through explicit naming registries, as covered in Google’s SRE book discussions of managing critical state. WordPress rewrite rules are that system here, and the slug character is the critical state.

That same discipline applies to naming decisions: before publishing, editors need a way to test labels, roles, and public-facing language stay consistent, which is where a character name generator that fits the project can function as a planning aid rather than a substitute for domain evidence.

What register_taxonomy() Actually Writes to the Rewrite Table

When you call register_taxonomy(), WordPress does several things. It inserts the taxonomy into the global $wp_taxonomies array. It registers the taxonomy’s query vars. And—critically—it adds rewrite rules to the rewrite rules array, which is stored in the rewrite_rules option in wp_options. The slug you pass as the rewrite argument (or the taxonomy name itself, if you don’t override it) becomes the URL prefix for term archives.

register_taxonomy( 'character', 'post', array(
    'rewrite' => array(
        'slug' => 'character',
        'with_front' => true,
        'hierarchical' => false,
    ),
    'public' => true,
    'show_in_rest' => true,
));

This call generates rewrite rules that match character/([^/]+)/?$ and map it to index.php?character=$matches[1]. The character query var is registered, and WordPress knows that when that query var is set, it should load a taxonomy archive template. So far, so good. The rules are generated on init, flushed to the database, and stored. The system is coherent.

But the rewrite_rules option is an ordered array. WordPress matches incoming URLs against this array in sequence—the first rule that matches wins. The order is not alphabetical. It is not by registration time. It is determined by WP_Rewrite::rewrite_rules(), which generates rules in a specific priority: rules for specific post types, then taxonomy rules, then date archives, then search, then pagination, then the catch-all page rule ((.?.+?)(?:/([0-9]+))?/?$) that matches anything that looks like a page path.

That catch-all page rule is the key. When you register character as a taxonomy slug, the taxonomy rule character/([^/]+)/?$ sits above the page rule in the array. So /character/romeo/ matches the taxonomy rule first. Good. But /character/ itself—without a term slug—does not match the taxonomy rule (which expects a term after the slug). It falls through to the page rule. And if no page with slug character exists, it 404s. If a page with slug character does exist, it matches the page rule and loads the page. The taxonomy archive for the taxonomy itself (the “all characters” view) was never generated by register_taxonomy()—only term archives were. So the page fills the vacuum.

The Latent Collision: Why It Surfaces Months Later

The failure mode is latent because the taxonomy works fine until the page is created. The rewrite rules don’t change. The page rule was always there, matching character as a potential page slug. There was just no page to match. When an editor creates the page, they are not modifying rewrite rules—they are creating a row in wp_posts with post_name = 'character'. But the rewrite engine doesn’t know the difference between “no page exists” and “a page exists but doesn’t match this URL.” It just tries rules in order, and the page rule matches character because character is a valid page-slug pattern.

The deeper problem is that the editorial team and the development team are using the same namespace—URL slugs—without a shared registry. The developer chose character as the taxonomy slug because it reads well in URLs. The editor created a page called “Character” because it reads well as a page title. Neither party knew the other had claimed the slug. Editors and developers both face naming-collision problems, and just as writers use tools like an character name generator to avoid name clashes in fiction, WordPress teams need a shared slug registry to avoid collisions in URLs. When editors name pages and developers name taxonomies without coordination, collisions are not a bug—they are an expected failure mode of an uncoordinated system.

This is also why the collision surfaces months later. The developer registered the taxonomy during the build. The editor created the page during a content sprint six months in. The time gap makes the failure feel mysterious—nothing changed in the code!—but the rewrite rules were always vulnerable. The page creation was the trigger, not the cause. The cause was the absence of a naming contract between editorial and development.

Tracing the Conflict: Reading rewrite_rules and query_vars

When you encounter this 404-or-wrong-page in production, the first instinct is usually wrong. You might check the taxonomy registration code, confirm the taxonomy is registered, confirm the term exists, and conclude the rewrite rules are “broken.” They are not broken. They are resolving correctly according to their priority order—you just don’t know what that order is. Here is how to trace it.

Step 1: Dump the rewrite_rules array

Run this with WP-CLI:

wp eval 'global $wp_rewrite; print_r( $wp_rewrite->rewrite_rules() );'

Or inspect the option directly:

wp option get rewrite_rules --format=json | jq 'to_entries[] | select(.key | startswith("character"))'

You will see something like this:

[character/([^/]+)/?$] => index.php?character=$matches[1]
[character/([^/]+)/feed/(feed|rdf|rss|rss2|atom)/?$] => index.php?character=$matches[1]&feed=$matches[2]
[(.?.+?)(?:/([0-9]+))?/?$] => index.php?pagename=$matches[1]&page=$matches[2]

The taxonomy rules are above the page catch-all. So /character/romeo/ should match the taxonomy rule. If it does not, the rules were not flushed after the taxonomy was registered, or something modified the array order. But if the rules look correct and you still get a 404 or wrong page, the problem is not in the rules array—it is in what happens after the rule matches.

Step 2: Inspect $wp_query->query_vars at template_redirect

Add a temporary debug hook:

add_action( 'template_redirect', function() {
    global $wp_query;
    if ( isset( $_GET['debug_query'] ) ) {
        wp_die( var_export( $wp_query->query_vars, true ) );
    }
});

Navigate to /character/romeo/?debug_query=1. You will see the query vars that WordPress resolved from the rewrite. If the taxonomy rule matched, you should see 'character' => 'romeo' in the array. If instead you see 'pagename' => 'character/romeo' or 'pagename' => 'character', the page rule won—meaning the taxonomy rule did not match, even though it appears earlier in the array.

The most common reason: the term slug is not romeo. Editors may have named the term “Romeo Montague” with slug romeo-montague. The URL /character/romeo/ does not match any term, so the taxonomy query returns empty, and WordPress falls back to the page rule. The 404 is correct behavior—the URL is wrong. But the failure feels like a rewrite bug because the taxonomy “used to work” (it did, for the terms that existed at the time).

Step 3: Check for reserved query_var collisions

WordPress has a list of reserved query vars in WP::$public_query_vars. If your taxonomy name or rewrite slug matches one of these, the query var registration silently fails or behaves unexpectedly. Check with:

wp eval 'global $wp; print_r( $wp->public_query_vars );'

If character appears in that array from another plugin or a custom registration, your taxonomy’s query var is competing. The rewrite rule points to ?character=romeo, but if two systems registered character as a query var, the resolution depends on which pre_get_posts callback runs last—a separate race condition that compounds the slug collision.

The Priority Order: Why the Page Rule Sometimes Wins

WordPress generates rewrite rules in WP_Rewrite::rewrite_rules() by iterating through registered post types, taxonomies, and other rule generators in a specific order. The rough priority is:

  1. Per-post-type rules (feeds, trackbacks, embeds, comments)
  2. Per-taxonomy rules (term archives, feeds)
  3. Date archive rules
  4. Search rules
  5. Pagination rules
  6. Root-level rules (home, front page)
  7. The page catch-all: (.?.+?)(?:/([0-9]+))?/?$

The page catch-all is intentionally last among the “named” rules because pages are the most generic URL pattern in WordPress—any hierarchical path could be a page. This is why /about/team/ loads a page, not a taxonomy term called “team.” But it is also why a page slug that collides with a taxonomy slug creates ambiguity: the taxonomy rule should win for /character/romeo/, but /character/ itself has no taxonomy rule to match (taxonomy rules expect a term), so the page rule fills the gap.

This is not a bug in the priority order. It is a design decision: pages are the fallback for any URL that doesn’t match a more specific rule. The failure is not in WordPress’s resolution logic—it is in the assumption that character as a taxonomy slug and character as a page slug can coexist without conflict. They cannot. They share a namespace, and the namespace has no collision detection.

The Fix: Treat Slugs as System Identifiers

The immediate fix is to rename one of the two. Either change the taxonomy rewrite slug to something that will not collide with editorial page names (e.g., characters plural, or by-character), or rename the page. Changing the taxonomy slug requires a rewrite flush and, if the site has been indexed, redirects from the old term archive URLs to the new ones:

register_taxonomy( 'character', 'post', array(
    'rewrite' => array(
        'slug' => 'by-character',
        'with_front' => true,
    ),
    // ...
));

// After registration, flush:
// wp rewrite flush

// Add redirects for old URLs:
add_action( 'template_redirect', function() {
    if ( is_404() ) {
        $req = $_SERVER['REQUEST_URI'];
        if ( preg_match( '#^/character/([^/]+)/?$#', $req, $m ) ) {
            wp_safe_redirect( home_url( "/by-character/{$m[1]}/" ), 301 );
            exit;
        }
    }
});

The deeper fix is to treat taxonomy slugs as system identifiers, not as human-readable labels. The slug character was chosen because it reads well in URLs, but it is also a word an editor might naturally use as a page title. The slug by-character is less likely to collide because it is not a natural page name—just as a writer using Reedsy’s character name generator picks names that fit a specific namespace and won’t clash with existing characters in the story.

The systems-engineering response is to create a shared naming registry. This does not need to be a complex tool. It can be a README in the theme repository that lists all registered taxonomy slugs, post type slugs, rewrite endpoints, and reserved query vars. Before an editor creates a page, they check the registry. Before a developer registers a taxonomy, they check the registry. The registry is the coordination layer that WordPress does not provide.

Here is a minimal version of what that registry should document:

  • Taxonomy slugs: The rewrite['slug'] value for every register_taxonomy() call, with the URL pattern it generates.
  • Post type slugs: The rewrite['slug'] value for every register_post_type() call, with the archive URL and single URL pattern.
  • Rewrite endpoints: Every add_rewrite_endpoint() call and the URL suffix it adds.
  • Reserved page slugs: A list of slugs that editors must not use for pages because they conflict with registered system identifiers.
  • Query vars: Every custom query var registered via add_filter( 'query_vars', ... ), to detect collisions with $wp->public_query_vars.

This registry is the schema-level documentation that prevents the latent collision. Without it, you are relying on memory and luck—two things that do not scale across a team or across six months of content creation.

Preventing the Next Collision: A Registration Audit

If you are inheriting a site where collisions may already be latent, run a registration audit. List all registered taxonomies and post types, their rewrite slugs, and check each against existing page slugs in wp_posts:

wp eval '
$taxonomies = get_taxonomies( array(), "objects" );
$post_types = get_post_types( array(), "objects" );

$slugs = array();
foreach ( $taxonomies as $tax ) {
    if ( isset( $tax->rewrite["slug"] ) ) {
        $slugs[ $tax->rewrite["slug"] ] = "taxonomy: " . $tax->name;
    }
}
foreach ( $post_types as $pt ) {
    if ( isset( $pt->rewrite["slug"] ) ) {
        $slugs[ $pt->rewrite["slug"] ] = "post_type: " . $pt->name;
    }
}

global $wpdb;
foreach ( $slugs as $slug => $source ) {
    $conflicts = $wpdb->get_var( $wpdb->prepare(
        "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_name = %s AND post_type = %s AND post_status = %s",
        $slug, "page", "publish"
    ));
    if ( $conflicts > 0 ) {
        echo "COLLISION: slug \"$slug\" ($source) conflicts with a published page\n";
    }
}
'

This will not catch every collision—hierarchical pages with matching parent slugs, or pages with slugs that match only part of a rewrite pattern, can also cause problems. But it will catch the most common case: a page slug that exactly matches a taxonomy or post type rewrite slug.

Run this audit after any register_taxonomy() or register_post_type() change, and after any bulk page import. Treat the output as a production incident if it finds a collision—not because the site is down, but because the collision will surface as a 404 or wrong-content response the next time an editor or crawler hits the affected URL.

Conclusion: Slugs Are Schema, Not Labels

The WordPress rewrite system is coherent. It resolves URLs according to a deterministic priority order, and it behaves correctly given the rules it has. The failure is not in the system—it is in the gap between the system’s assumptions and the team’s practices. The rewrite engine assumes that slugs are unique across all rule generators. The team treats slugs as human-readable labels that can be chosen independently by editors and developers. Those two assumptions cannot both hold.

The fix is not a plugin, a hook, or a rewrite rule. It is a naming contract: a shared registry of system identifiers that both editorial and development teams consult before claiming a slug. The contract is simple, low-tech, and boring. It is also the only thing that prevents the next register_taxonomy() call from colliding with the next page an editor creates six months from now. Treat slugs as schema. Document them. Audit them. And when a 404 traces back to a slug collision, treat it as a naming-registry failure, not a rewrite bug—because that is what it is.

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

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

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

What enqueue_block_assets Actually Does

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

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

The Hook Order in a Default Theme

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

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

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

Reproducing the Failure with a Minimal Plugin

Create a plugin with this code:

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

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

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

Now reverse the pattern:

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

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

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

Inspecting the Enqueue Chain with WP-CLI

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

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

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

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

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

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

Why the Frontend Render Path Is Different

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

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

Checking block.json Registration

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

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

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

Fixing the Hook Order Without Breaking the Editor

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

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

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

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

When the Theme Is the Culprit

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

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

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

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

FAQ

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

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

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

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

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

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

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

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

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

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

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

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

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

Start With the Stored Post Content, Not the Rendered Page

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

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

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

If you prefer WP-CLI, the equivalent is:

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

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

Identify the Shortcode Signature and Its Attribute Shape

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

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

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

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

Recover Attribute Semantics From Adjacent Content

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

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

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

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

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

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

Map the Shortcode to a Known Plugin or Core Feature

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

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

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

Reconstruct the Intent From Attribute Values Alone

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

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

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

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

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

Use the Block Editor as a Reconstruction Sandbox

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

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

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

Query the Database for Shortcode Usage Patterns

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

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

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

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

Reconstructing a Shortcode That Wrapped Content

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

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

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

When the Shortcode Is a Data Source, Not a Renderer

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

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

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

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

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

Document the Reconstruction for Future Editors

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

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

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

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

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

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

Common Failure Modes and Their Reconstructions

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

1. Plugin Deactivated, Shortcode Left Behind

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

2. Theme Change Removed a Page Builder Shortcode

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

3. Shortcode Callback Fatal Error

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

FAQ

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

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

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

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

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

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

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

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

How do I prevent shortcode breakage in the future?

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

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

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

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

Editor comparing raw post content with rendered output in WordPress admin

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

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

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

How WordPress Stores Transients in the Database

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

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

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

Reproducing the Collision with SQL

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

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

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

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

Reproducing the Collision with WP-CLI

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

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

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

The Exact Collision: Network-Wide Transients and Shared Keys

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

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

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

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

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

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

Why Plugins Accidentally Use Network-Wide Transients

Most collisions come from three patterns:

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

How to Namespace Transient Keys Correctly

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

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

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

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

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

Namespacing for Network-Wide Transients

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

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

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

Using a Prefix Constant

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

define( 'MY_PLUGIN_PREFIX', 'my_plugin_' );

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

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

Flushing Transients Without Causing Collisions

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

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

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

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

Real Failure Mode: Cron Jobs and Switch_to_blog

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

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

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

Testing Your Transient Keys

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

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

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

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

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

FAQ

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

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

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

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

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

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

Does WordPress core automatically namespace transient keys per blog?

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

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

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

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

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

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

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

What wp-cron Actually Is

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

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

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

First Evidence: Check the Queue Directly

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

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

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

SELECT option_value FROM wp_options WHERE option_name = 'cron';

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

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

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

What a Stuck Queue Looks Like

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

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

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

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

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

Test the Loopback Request

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

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

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

Shared Hosting Failure Modes

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

1. Loopback Requests Are Blocked

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

2. The Server Cannot Resolve Its Own Domain

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

3. PHP Execution Time Limits

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

4. The ALTERNATE_WP_CRON Fallback Is Not Set

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

define('ALTERNATE_WP_CRON', true);

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

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

The System Cron Fix

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

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

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

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

Or if wget isn’t available:

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

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

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

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

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

Diagnosing with WP-CLI

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

List All Events

wp cron event list

Run a Specific Event Immediately

wp cron event run 

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

Run All Due Events

wp cron event run --due-now

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

Check the Cron Option Directly

wp option get cron --format=json

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

Common Plugin Interactions

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

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

When the Queue Itself Is Corrupt

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

To check for corruption, run:

wp eval 'var_dump(_get_cron_array());'

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

wp option delete cron

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

Editorial Workflow Implications

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

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

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

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

FAQ

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

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

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

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

How do I know if the cron queue is corrupt?

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

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

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

Next Steps for Your Install

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